Skip to Content
API Referenceai-server

@stesura/ai-server

Route handler factories for the AI feature (Anthropic, through the AI SDK) and the request guard that protects them. Each factory returns a (req: Request) => Promise<Response> handler to mount as a POST route; see Server routes for setup.

Route handlers

createTextActionHandler

Route handler factory for the @stesura/ai text actions. Mount it as a POST handler (Next.js app router: export const POST = createTextActionHandler({ model })).

Request: { actionId, target, before?, after?, params? } — an action id the SERVER has a prompt template for, plus data fragments it fences into that template. The prompt never travels: a client-authored system string turned the route into a generic completion proxy on the operator’s key (AIS-2). Response: { text: string }.

Auth and rate limiting are deliberately the host app’s responsibility — wrap the returned handler in withRequestGuard or your middleware of choice.

Parameters:
NameTypeDefault
optionsCreateTextActionHandlerOptions
Returns:
(req: Request) => Promise<Response>

createCompletionHandler

Same contract as createTextActionHandler, tuned for tab autocomplete: a fast, cheap model and a tight output cap. Mount on its own route so completions and text actions can be metered separately.

Parameters:
NameTypeDefault
optionsCreateTextActionHandlerOptions
Returns:
(req: Request) => Promise<Response>

CreateTextActionHandlerOptions

Options for createTextActionHandler and createCompletionHandler.

NameTypeDefault
modelLanguageModel

Any AI SDK model, constructed by the host with its own key. Text actions are short rewrites; autocomplete wants a fast, cheap one.

maxOutputTokensnumber

Response token cap. Defaults to 4096 (64 for the completion handler).

maxPromptCharsnumber

Reject prompts above this length (defence against oversized payloads).

maxBodyBytesnumber

Reject request bodies above this many bytes BEFORE buffering them — the schema caps only run once the whole body is in memory. Default 1 MB (the schema’s fragment caps total well under it).

timeoutMsnumber

Abort a hung provider call after this long and answer 504 (0 disables). req.signal only covers the client hanging up; without a deadline a stuck upstream holds the request for as long as the platform tolerates. Default 60s (15s for the completion handler).

maxRetriesnumber

Provider retries on 429/5xx. The SDK waits out retry-after between attempts, so the budget has to fit inside timeoutMs: a wait that runs past the deadline answers 504, and the client loses the retry-after it would have backed off on. Default 1 — one wait fits the 60s deadline, two typically don’t (0 for the completion handler: its 15s deadline cannot afford any, and autocomplete backs off client-side).

actionsRecord<string, AiActionTemplate>

Extra prompt templates, keyed by action id, merged over the built-ins. Server-side by design: a custom action is a prompt the OPERATOR wrote, and the wire has no way to supply one. Note the body schema strips unknown keys, so a template only ever receives target/before/after and the built-in params fields — variant behavior belongs in distinct action ids, not a free-form params channel.

maxConcurrentnumber

Requests handled at once in this process; past it the route answers 503 + retry-after: 1. Off by default: bodies are capped at maxBodyBytes (1 MB), so concurrency is not a memory risk here, and a shared cap would turn many users’ autocomplete pauses into 503s. Set it to bound spend.

createChatHandler

Streaming chat route with document tools. Editor tools carry no execute — the client runs them against the editor (via executeAiTool) and sends the results back with the next request, so the model can chain read → edit. Server tools run here, inside the loop, under the serverTools policy.

Request: { messages: UIMessage[], docContext?, attachments?, model?, mode? } — model must come from the configured models allowlist; mode: "ask" restricts the declared tool set to read-only tools (the gate is server-side on purpose — client-side filtering is UX, not enforcement). Messages may only carry the user and assistant roles: the system prompt is the server’s. Response: UI message stream (AI SDK).

Auth and rate limiting are the host app’s responsibility (withRequestGuard).

Parameters:
NameTypeDefault
optionsCreateChatHandlerOptions
Returns:
(req: Request) => Promise<Response>

CreateChatHandlerOptions

Options for createChatHandler. A model is required: pass model, or a models allowlist the client picks from by id (the body’s model field; the first entry is the default, an id outside it is a 400). Any AI SDK provider: the host constructs it with its own key.

NameTypeDefault
modelLanguageModel | undefined
modelsRecord<string, LanguageModel> | undefined
toolsAiToolSet

The tools the model may call: editor tools (declared here, run in the browser) and the host’s server tools (run here). Defaults to the built-ins. Build it with createAiToolSet in a module the editor page imports too.

toolSearch"auto" | "off"

auto (default): Anthropic’s native tool search on models that support it, the portable find_tools everywhere else. Only deferred tools are searched; a set with none sends every tool. off sends every tool.

serverTools{ timeoutMs?: number; maxResultChars?: number; onCall?: (event: ServerToolCallEvent) => void; }

Server tools: limits and the audit log, enforced here rather than by each tool.

toolApprovalSecretstring

Signs the approval requests of side-effecting (config) server tools, so a client cannot forge an approval the user never gave. Required when the set has any.

maxStepsnumber

Model steps per request; server tool calls continue the loop. Default 8.

maxOutputTokensnumber

Response token cap per request. Defaults to 8192.

systemstring

Extra system prompt appended after the built-in one (product voice, house rules).

promptCachingboolean

Mark the stable prompt prefix (system rules + attachments) as cacheable, so a multi-turn conversation over a big PDF bills the document once per TTL instead of once per turn. Default true.

maxBodyBytesnumber

Reject request bodies above this many bytes BEFORE buffering them — the schema caps only run once the whole body is in memory. Default sized for the attachment budget plus conversation text.

timeoutMsnumber

Abort a hung stream after this long (0 disables). req.signal only covers the client hanging up. Generous by default (5 min) — a legitimate long answer streams for minutes, and platform maxDuration usually cuts in earlier anyway; this is the backstop for bare Node.

attachmentCachefalse | AttachmentCacheOptions

Upload dedup (AIS-5): remember attachments per conversation so the client can send a hash reference instead of re-uploading the bytes on every request and tool round. In-memory and best-effort — a miss answers attachments-missing and the client re-sends, so serverless cold starts merely cost one retry. false disables (every request carries full data).

principal(req: Request) => string | undefined

Who is calling: the attachment cache’s scope (AIS-14), and the user server tools authorise as. A host with its own auth (cookies, sessions, middleware) MUST pass this: without it the scope falls back to the raw Authorization header, so every user behind one shared token or an allowAnonymous guard shares one principal — and can resolve each other’s cached attachments given a conversation id and a content hash. Return the authenticated user id; undefined falls back.

maxConcurrentnumber

Requests handled at once in this process; past it the route answers 503 + retry-after: 1 before reading the body (AIS-16). The rate limit is per client, so without this N clients cost N × the peak below. Peak memory per request is roughly 4× the body — the decoded string, the parsed JSON, the decoded PDF bytes and the SDK’s base64 re-encode — so ~125 MB at the default maxBodyBytes (~31.6 MB), and the default of 8 is ~1 GB. Size it against your container, not this default. 0 disables.

Custom actions

Extra prompt templates go in the text-action handler’s actions option, keyed by action id. See Prompts live on the server.

AiActionTemplate

Builds the server-owned prompt for a built-in action.

Parameters:
NameTypeDefault
paramsTextActionParams
payloadAiActionPayload
Returns:
{ system: string; prompt: string; }

Request guard

See Securing the routes.

withRequestGuard

Minimal protection wrapper for the AI route handlers. This is a floor, not a substitute for the host app’s real auth:

export const POST = withRequestGuard(createChatHandler(), { bearerToken: process.env.AI_ROUTE_TOKEN, rateLimit: { windowMs: 60_000, max: 20 }, });

Throws when neither bearerToken nor allowAnonymous is set: an unauthenticated LLM proxy is never the right default.

Parameters:
NameTypeDefault
handlerHandler
optionsRequestGuardOptions
Returns:
Handler

RequestGuardOptions

Options for withRequestGuard: set bearerToken, or opt out with allowAnonymous.

NameTypeDefault
bearerTokenstring

Static bearer token checked against the Authorization header (Authorization: Bearer <token>). Required unless allowAnonymous is explicitly set — an absent token must never silently mean “open proxy”.

allowAnonymousboolean

Serve requests without any authentication. Explicit on purpose: the routes spend the operator’s model budget, so opting out has to be a decision someone wrote down, not an omission.

getClientKey(req: Request) => string

Rate-limit key for an unauthenticated request. Defaults to the x-forwarded-for / x-real-ip chain, which is only meaningful BEHIND A TRUSTED PROXY that overwrites those headers — anywhere else a client picks its own key. Supply your own (session id, user id, signed cookie) when the deployment can’t guarantee that.

rateLimit{ windowMs?: number; max?: number; }

Fixed-window in-memory rate limit. Per-process only: on serverless or multi-instance deploys each instance counts separately — use an edge/middleware limiter (or a Redis-backed one) for hard guarantees.

Last updated on