pivotkit-server (REST)¶
pivotkit-server serves pivotkit's reports to programs over HTTP: a script, a PowerShell
Universal endpoint, a scheduler or a web app lists data sources, checks a view,
renders it into a file and makes links. It runs the same operations as the
MCP server and the command line, with the same exposure policy, so a program sees
of a source what the operator allows. The package also holds what the MCP server is built on, so
pivotkit-server serve runs the REST service and, when pivotkit-mcp is installed beside it, the MCP
endpoint on the same port.
To try it step by step, see Getting started: REST service; to run it as a container, see Container image. This page is the reference.
npm install --global pivotkit-server pivotkit-mcp
PIVOTKIT_TOKEN=... pivotkit-server serve --config pivotkit.json --auth token # http://127.0.0.1:3333/api/v1
Node 22 or later. It brings pivotkit-report, pivotkit-cli's file handling, pivotkit-xlsx, ECharts 6.1.0 and jose (for OAuth); not the MCP SDK.
Routes¶
Every route is under /api/v1 and answers JSON. The routes are RPC-style (POST /views/validate),
one per operation of the operation table:
| Route | Operation | What it does |
|---|---|---|
GET /sources |
list_sources |
The sources the caller may use: id, title, whether a link or a schedule works |
GET /sources/{id} |
describe_source |
A source's fields with their type, level and counts, the aggregators and filters each works with, example views |
POST /sources/describe |
describe_source |
The same, with { "source": "<id>" } |
POST /views/suggest |
suggest_view |
A draft view from the source's fields, with the fields it picked (picks); a layout replaces the parts it gives |
GET /views/guide |
get_view_guide |
How to write a view: its keys, the vocabulary, example views |
POST /views/validate |
validate_view |
Errors and warnings of a view against a source, each with its place and, for a misspelt name, the name meant (suggestion) |
POST /views/describe |
describe_view |
A view in plain parts with the same findings |
POST /views/render |
render_view |
The report as xlsx (default), html, csv, json or svg, saved in the output directory |
POST /views/render/file |
render_view |
The same report as the answer itself, a download (below) |
POST /views/link |
create_link |
A link that opens the view in the source's report page |
POST /schedules/draft |
draft_schedule |
A draft of a scheduled report for a person to approve; runs and sends nothing |
A POST takes its input as JSON (Content-Type: application/json, at most 2 MiB). The input and the
answer of each route are in the OpenAPI document. A view with errors is no error: the
answer is 200 with ok or valid false and findings that say what to fix (the file route answers
422). validate, render and render/file take a layout instead of a view: the view by field names
(rows, columns, values, groupings), as the command line's layout flags write it
(layout); the answer then holds the view it made. suggest takes one too,
whose parts replace those of the draft. source is the id of a source the operator configured: files by path (file:) are refused
over REST (FILES_NOT_ALLOWED), whatever files.allowDirs allows the MCP server.
curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"source":"sales","view":{"rows":["Region"],"values":[{"field":"Sales","agg":"sum"}]},"format":"xlsx"}' \
http://127.0.0.1:3333/api/v1/views/render
{ "ok": true, "findings": [], "uses": ["Region", "Sales"], "format": "xlsx",
"mediaType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"withheld": ["rows", "columns", "bytes", "content"],
"output": { "name": "sales.xlsx", "format": "xlsx", "mediaType": "..." },
"message": "The report is saved as sales.xlsx in the output directory. Its content is not part of this answer, since the operator does not show the values of Sales." }
render_view saves the file in the server's output directory (output.dir) and says where
(output.name, and output.url when output.baseUrl is set). The file's name is name from the
input, else title, else the view's name, else the source's; a name that is taken gets -2, -3
and so on, since no file is ever replaced. Check ok in the answer: a view with errors answers 200
with ok: false and output: null. The caller fetches the file from there or
uses the file route. The field names in these examples are made up: the fields of your
source and views built from them are in GET /sources/{id} (fields, examples).
A csv result is the report's layout, as the HTML report and the workbook show it: a header row per column field, a row of labels, then a row per group with a total row and column; it starts with a UTF-8 byte order mark, so a spreadsheet opens it as UTF-8.
A csv or json result comes back inline (inline.text) only when the operator sets
rest.inline: true and every field the view uses is shown. withheld lists what is left out of the
answer because the view uses a field whose values the policy does not show. For such a view, the
findings that would tell what the data holds (an item spelt differently, a filter that keeps nothing)
are left out too: a filter on an item that does not exist validates and renders an empty report.
Exposure policy and profiles¶
The exposure policy is the MCP server's: per field show, summary (default) or
hide, per source over the server's. A REST caller is a program, not a model, but the same rules
hold: a field at summary keeps its values out of every answer.
A profile gives one caller more (or less) than the default: a named policy of the same shape in
policy.profiles, bound to a static token (auth.tokens[].profile), an OAuth client
(auth.clientProfiles) or an OAuth scope (auth.scopeProfiles). It is laid over each source's
policy:
- a field the source's policy hides stays hidden in every profile (it is dropped when the data is read);
- any other field takes the profile's level for it, else the profile's
default, else the source's level; items,rows,counts,inlineandresultsare the profile's where it sets them (a profile withresults: truemay download from a source whose own policy saysresults: false);- a caller without a profile gets the server's policy, never more. An OAuth token whose scopes name two different profiles gets none.
{
"sources": { "sales": { "file": "data/sales.csv", "policy": { "fields": { "Region": "show", "Customer": "hide" } } } },
"policy": {
"profiles": {
"analyst": { "default": "show" },
"export": { "results": true }
}
},
"auth": { "mode": "token", "tokens": [
{ "name": "dashboard", "file": "/run/secrets/dashboard", "profile": "analyst" },
{ "name": "nightly-export", "file": "/run/secrets/export", "profile": "export" },
{ "name": "scripts", "file": "/run/secrets/scripts" } ] }
}
Here the dashboard sees every value of sales except Customer; the nightly export may download
files; the scripts get the default. results: true means: this caller may read every value of every
field that is not hidden, in a file, whatever level the field has; give it only to callers that may. Profiles apply to the MCP endpoint too (a model's token with the
analyst profile sees values), except results, which only the file route reads.
| Key | Default | What it is |
|---|---|---|
policy.profiles.<name> |
none | A profile: default (show or summary), fields, items, rows, counts, inline, results |
policy.results, sources.<id>.policy.results |
false | Downloads through the file route for callers without a profile, per source |
auth.tokens[].profile |
none | A static token's profile |
auth.clientProfiles |
none | OAuth client id (azp or client_id) to profile: { "report-bot": "export" } |
auth.scopeProfiles |
none | OAuth scope to profile: { "pivotkit:export": "export" } |
Downloads¶
POST /views/render/file takes the input of /views/render and answers with the file itself, as an
attachment. It is allowed only for a caller whose policy has results: true (a profile's, a source's
or the server's), whatever fields the view uses: an answer that depended on the view's fields would
itself tell what they hold. Without it the answer is 403 NOT_ALLOWED. Nothing is written to the
output directory, and every download is a log event of its own.
| Header | Value |
|---|---|
Content-Type |
the format's: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv;charset=utf-8, application/json, text/html;charset=utf-8, image/svg+xml |
Content-Disposition |
attachment; filename="..."; filename*=UTF-8''... (the name without path or control characters, at most 80 characters) |
X-Content-Type-Options |
nosniff |
Cache-Control |
no-store |
Content-Security-Policy |
sandbox; default-src 'none' (an HTML or SVG report runs nothing when a browser opens it) |
A view with errors answers 422 VIEW_HAS_ERRORS with its findings.
curl -s -o report.xlsx -H "Authorization: Bearer $EXPORT_TOKEN" -H "Content-Type: application/json" \
-d '{"source":"sales","view":{"rows":["Region"],"values":[{"field":"Sales","agg":"sum"}]}}' \
http://127.0.0.1:3333/api/v1/views/render/file
Sign-in¶
The modes are the MCP server's, from the same auth settings:
| Mode | How | 401 answers with |
|---|---|---|
none |
No check; loopback only unless auth.allowUnauthenticated |
- |
token |
Authorization: Bearer <token>: $PIVOTKIT_TOKEN, or named tokens in auth.tokens (from a variable or a file read on every request) |
WWW-Authenticate: Bearer realm="pivotkit" |
oauth |
A JWT access token of the issuer for the REST resource | WWW-Authenticate: Bearer error="invalid_token", ..., resource_metadata="..." |
Only the Authorization header is read: a token in the address (?access_token=), two
Authorization headers, Basic and cookies are refused. Static tokens and their scopes work on both
endpoints. With OAuth the REST service is a protected resource of its own: its resource is
rest.resource (default the origin of auth.resource with /api/v1, e.g.
https://reports.example.com/api/v1), its metadata (RFC 9728)
is at /.well-known/oauth-protected-resource/api/v1, and a token must carry its audience: a token
issued for the MCP endpoint is refused by REST, and one for REST by the MCP endpoint. A token without
the required scopes gets 403 INSUFFICIENT_SCOPE with WWW-Authenticate: Bearer
error="insufficient_scope", scope="..." naming them.
Set auth.requireTyp: "at+jwt" where the provider marks its access tokens so (RFC 9068), so an ID
token with the right audience is refused.
Every token that may render can fill the output directory up to output.maxFiles; then rendering
into it answers 409 NO_OUTPUT for everyone until files are removed. Set output.maxAgeDays, and
give render_view a scope of its own (auth.toolScopes) when not every caller needs it. The server
holds at most 256 connections, and a client has 15 seconds to send its headers: on a network you do
not trust, put a reverse proxy in front.
Scopes per operation (auth.toolScopes, e.g. { "render_view": ["pivotkit:render"] }) and per
source (sources.<id>.scopes) hold as for MCP; a source the caller may not use answers as a missing
one (404 SOURCE_NOT_FOUND).
| Key | Default | What it is |
|---|---|---|
rest.resource |
origin of auth.resource + /api/v1 |
The REST service's resource (OAuth) |
rest.audience |
[rest.resource] |
The aud values a REST token may carry |
rest.requiredScopes |
auth.requiredScopes |
Scopes every REST call needs |
CORS¶
Off by default: a request with an Origin header (from a browser) is refused with 403
FORBIDDEN_ORIGIN; programs send none. rest.cors.origins lists the browser origins allowed to call,
each exactly as a browser sends it (https://app.example.com, never *). For them a preflight
(OPTIONS) is answered before sign-in, and every answer, errors included, carries
Access-Control-Allow-Origin with that origin and Vary: Origin. Cookies are never read, so no
credentials mode is needed: the page sends the token in the Authorization header. MCP's
http.allowOrigins is a separate list.
| Key | Default | What it is |
|---|---|---|
rest.cors.origins |
none | Browser origins allowed to call |
rest.cors.maxAge |
600 | Seconds a browser may keep a preflight's answer |
Limits¶
Besides pivotkit-core's limits and the workers' (compute.*, as for
MCP), the REST service limits each caller (a static token's name, an OAuth
client id, or local without sign-in):
| Key | Default | What it is |
|---|---|---|
rest.rateLimit.perMinute, .burst |
120, 30 | Requests per minute, with up to burst at once after a pause |
rest.rateLimit.failedAuthPerMinute |
30 | Refused tokens per address and minute; beyond, a wrong token from that address gets 429 instead of 401 (a right one still works) |
rest.concurrency |
4 | Requests of one caller at once |
rest.inline |
false | csv and json results inline in the answer, where the policy allows |
Over a limit the answer is 429 RATE_LIMITED with Retry-After. The counts live in the memory of
one process: two instances behind a load balancer count on their own. The address is the socket's,
or with http.trustProxy the last one in X-Forwarded-For.
Errors¶
Every error is an RFC 9457 problem document,
application/problem+json:
{ "type": "https://pivotkit.eize.dev/reference/rest/#error-source-not-found", "title": "Source not found",
"status": 404, "code": "SOURCE_NOT_FOUND", "detail": "no source \"salse\"; did you mean \"sales\"?; GET /sources lists them" }
code is stable; detail says what went wrong and what to do, and names a file by its base name at
most, never its content. A 5xx answer carries a fixed text, never an error's message. 429 and 503
carry Retry-After.
| Status | Code | When |
|---|---|---|
| 400 | INVALID_INPUT |
The body is not valid JSON, is no JSON object, or does not fit the route's schema (an unknown key, a wrong type, nested deeper than 64 levels, also inside a view given as JSON text) |
| 401 | INVALID_TOKEN |
No token, an unknown or expired one, another scheme, a token in the address |
| 403 | INSUFFICIENT_SCOPE |
The token lacks a scope the service or the operation needs |
| 403 | NOT_ALLOWED |
The file route without results: true |
| 403 | FILES_NOT_ALLOWED |
A file: source |
| 403 | FORBIDDEN_HOST |
A Host the server does not answer to (DNS rebinding protection; http.allowHosts, http.publicUrl) |
| 403 | FORBIDDEN_ORIGIN |
A browser origin not in rest.cors.origins |
| 404 | NOT_FOUND |
No such route |
| 404 | SOURCE_NOT_FOUND |
No such source, or one the caller lacks the scopes for |
| 404 | FILE_NOT_AVAILABLE |
The source's file is missing or changed while read |
| 405 | METHOD_NOT_ALLOWED |
GET for a POST route or the other way round (Allow names the method) |
| 409 | NO_OUTPUT |
No output directory for a file, or the directory is full |
| 409 | NO_LINK, NOT_SCHEDULABLE |
A link for a source without a page; a schedule over a source that cannot have one |
| 413 | BODY_TOO_LARGE, LIMIT_EXCEEDED |
The body over 2 MiB; a file, a view or the work over a limit |
| 415 | UNSUPPORTED_MEDIA_TYPE, UNSUPPORTED_FORMAT |
A POST whose Content-Type is not application/json (curl's -d alone sends a form); a source file in no data format |
| 422 | INVALID_FILE, NOT_REGULAR_FILE, HARD_LINK, INVALID_TEMPLATE |
The source's file or print template cannot be used |
| 422 | VIEW_HAS_ERRORS |
The file route for a view with errors (with findings) |
| 429 | RATE_LIMITED |
A limit |
| 500 | INTERNAL |
Anything else; the details only in the log with --log debug |
| 503 | BUSY, NOT_READY, AUTH_NOT_READY |
No worker free; starting or stopping; the OAuth issuer not loaded yet |
| 504 | TIMEOUT |
A report over compute.timeMs |
The OpenAPI document¶
A running server serves its OpenAPI 3.1 document at /openapi.json, with its own address
(http.publicUrl) and sign-in (bearer for tokens, the issuer's OAuth endpoints, the scopes per
operation as x-pivotkit-scopes). By default only for signed-in callers (rest.openapi: "auth");
"public" serves it to anyone, "off" not at all. The view's JSON Schema
(view-2.json) is bundled into it, so a client fetches nothing else. The document of a
server with static tokens is also published at
pivotkit.eize.dev/openapi.json.
The document is generated from the operation table and the schemas the server presents, the same
ones the MCP tools have, so routes, docs and checks cannot drift apart. /api/v1 changes only with a
breaking change of the operations' contracts (a breaking change for MCP too); info.version is
pivotkit-server's.
Running it¶
pivotkit-server is one command for the REST service, the MCP server and the command line, as the
container image runs them:
| Command | What it does |
|---|---|
pivotkit-server serve [flags] |
HTTP on one port: /api/v1, /openapi.json, and with pivotkit-mcp installed /mcp and its quick-start page; /healthz and /readyz |
pivotkit-server check-config [flags] |
What serve would expose (sources, fields by level, profiles, REST settings), then exit; 1 when a file it names is missing |
pivotkit-server mcp [flags] |
pivotkit-mcp's runner (stdio by default) |
pivotkit-server cli <command> ... |
The command line pivotkit |
pivotkit-server healthcheck [url] |
Exit 0 when GET /healthz answers (default http://127.0.0.1:$PIVOTKIT_PORT/healthz) |
The flags of serve are the MCP runner's without --http, --allow-dir
and --list-files, plus --enable rest,mcp (http.enable: what to serve; default REST, and MCP
when pivotkit-mcp is installed). The configuration file is --config or $PIVOTKIT_CONFIG; the
one static token without auth.tokens is $PIVOTKIT_TOKEN; $PIVOTKIT_PORT sets the port when
neither the flag nor the file does. It is one configuration for both endpoints: rest.* is read by
the REST service only, http.page and http.allowOrigins by the MCP endpoint only.
/healthz answers { "ok": true } while the process runs; /readyz answers 200 when it can take
work ({ "ok": true, "checks": { "auth": true, "workers": true, "output": true } }: the OAuth issuer
loaded, workers up, the output directory writable) and 503 otherwise. On SIGTERM the server takes no
new connection and lets running requests finish for up to http.graceMs (25 seconds by default).
| Key | Default | What it is |
|---|---|---|
http.enable |
["rest"], plus "mcp" when installed |
What serve serves |
http.trustProxy |
false | Read the client's address from X-Forwarded-For (the last entry) for the limits and the log |
http.graceMs |
25000 | How long a stop waits for running requests |
output.maxAgeDays |
none | Delete reports and drafts (.xlsx, .html, .csv, .json, .svg) older than this from the top level of the output directory, at start and every hour; other files and subdirectories stay. Use a directory of its own: the server refuses to start when the output directory is the configuration file's directory or holds a source, print template or token file |
rest.openapi |
"auth" |
Who gets /openapi.json |
Behind a reverse proxy: forward /api/v1/, /openapi.json and /.well-known/oauth-protected-resource/api/v1
(with MCP also /mcp, /.well-known/ and /), keep the Host header (or add the proxy's name to
http.allowHosts), and set http.publicUrl. Set http.trustProxy when the proxy adds the client's
address to X-Forwarded-For: otherwise every client has the proxy's address and shares one count of
refused tokens (the limit per address). Only when the service is reachable through the proxy alone:
a client that reaches the server directly can write any X-Forwarded-For. With it, the log names the
client's address. Let the proxy take bodies of 2 MiB (nginx's default is 1 MiB). For nginx:
location ~ ^/(api/v1/|openapi\.json$|mcp$|\.well-known/|healthz$|readyz$|$) {
proxy_pass http://127.0.0.1:3333;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 60s;
client_max_body_size 2m;
}
Without containers, on Windows next to PowerShell
Universal, pivotkit-server serve from a global npm install runs as a service with a service
manager of your choice; its file checks are measured on Linux and macOS
(#209 tracks junctions and hard links on Windows).
Logging¶
One JSON line per event on stderr, as the MCP server's: started (version,
address, what it serves, auth mode), request (operation, route, client, source id, status, error
code, time in ms), download (operation, client, source, format, bytes), refused (a request refused
by the Host, Origin or token check) and warning. The client is the static token's name or the OAuth
client id, never the person's id; no line holds a view, a value, a token, a path or, unless
http.trustProxy is set, a client address.
In your own server¶
pivotkit-server is a library too. createContext(options) checks the options and starts the
workers; createRestHandler(context) from pivotkit-server/rest returns a fetch handler (a
Request in, a Response out) for any server that speaks them:
import { createContext, serveFetch } from "pivotkit-server";
import { createRestHandler } from "pivotkit-server/rest";
const context = await createContext({
sources: { sales: { load: async () => rows, version: () => rowsVersion, policy: { fields: { Region: "show" } } } },
output: { dir: "/srv/reports" },
auth: { mode: "token" }
});
const rest = await createRestHandler(context, { env: process.env, loopback: true });
// on node:http with the checks of this page (body cap, slow clients, a graceful stop):
const server = await serveFetch(rest, { host: "127.0.0.1", port: 3333, bodyBytes: 2_097_152 });
// or in a framework: return rest.fetch(request, { address });
A source may be load: async () => rows instead of file: an array of row objects, or columnar data
{ columns, types, data } with one array per column (not encode()'s result, so hidden fields can
be dropped). It runs in the host's thread, not in a worker. With version: () => key the encoded data
is kept until the key changes (per process, by source id and key); without it, every request loads
again. An error thrown by load answers 500 with a fixed text; its class is logged, its message with
log: "debug".
- The one static token without
auth.tokensis$PIVOTKIT_TOKEN(createContext(options, { tokenEnv })names another); tokens are read fromenvofcreateRestHandler. loopbacksays whether the server listens on loopback only; it decides whetherauth: noneis allowed, so it must match the address you listen on.rest.fetch(request, { address })takes the client's address for the limit on refused tokens; without it, refused tokens are not limited (a warning says so once).- The Host check reads the request's
Hostheader (aRequestbuilt in code without one: its URL's host); it acceptslocalhost,127.0.0.1,[::1], the host ofhttp.publicUrlandhttp.allowHosts. createServerHandler(options, { env, stderr, loopback })is the handler ofpivotkit-server serve: REST and, when pivotkit-mcp is installed, MCP over one context, with/healthzand/readyz; hand it toserveFetchor your own server. To combine them yourself, build the context with pivotkit-mcp'screateMcpContext(itscreateHttpHandlerneeds the MCP server factory it adds) and give it to bothcreateHttpHandlerandcreateRestHandler.openApiDocument(options)builds the OpenAPI document,REST_ROUTESlists the routes, andpresent(context, operation, input, caller)runs an operation for a caller (callerOf("none")without sign-in) and gives{ text, result }as both endpoints do.serve(options, { env, stderr })ispivotkit-server servein code.
PowerShell Universal¶
A PowerShell Universal script, endpoint or app calls the REST service with Invoke-RestMethod
instead of running Node on the PSU server: keep the token in a PSU secret, call
POST /api/v1/views/render/file with a token whose profile has results: true and hand the file to
the user, or POST /api/v1/views/render to save it in the service's output directory. See
Getting started: REST service for the calls in PowerShell.
The links of the PSU module's reports open as before.
Security¶
See Security: the REST service for what the service protects against and what it does not, and Privacy for what an operator decides about the data it serves.