AI
The AI feature pack adds three surfaces to the editor: text actions (rephrase, translate, … from the text menu), tab autocomplete (ghost-text completions), and a document chat that reads and edits the document through tools. Every content edit the AI makes lands as a track-changes suggestion — the user’s accept/reject is the safety boundary; nothing is ever auto-applied.
The packages are backend-agnostic: model calls go through transports and route handlers the host app provides. @stesura/ai-server ships ready-made Anthropic handlers.
Packages
| Package | Purpose |
|---|---|
@stesura/ai | Headless core: autocomplete plugin, text-action engine, chat tool system, HTTP transport |
@stesura/ai-react | Text-menu dropdown, chat panel, UI extension |
@stesura/ai-server | Route handler factories (Anthropic via the AI SDK) + request guard |
Setup
1. Server routes
Mount the three handlers as POST routes (Next.js app router shown). They read ANTHROPIC_API_KEY from the environment by default.
// app/api/ai/textAction/route.ts
import { createTextActionHandler, withRequestGuard } from "@stesura/ai-server";
export const maxDuration = 60;
export const POST = withRequestGuard(createTextActionHandler(), {
bearerToken: process.env.AI_ROUTE_TOKEN,
rateLimit: { windowMs: 60_000, max: 30 },
});// app/api/ai/completion/route.ts
import { createCompletionHandler, withRequestGuard } from "@stesura/ai-server";
export const maxDuration = 30;
// Autocomplete fires on every typing pause — higher ceiling, still bounded.
export const POST = withRequestGuard(createCompletionHandler(), {
bearerToken: process.env.AI_ROUTE_TOKEN,
rateLimit: { windowMs: 60_000, max: 120 },
});// app/api/ai/chat/route.ts
import { createChatHandler, withRequestGuard } from "@stesura/ai-server";
export const maxDuration = 120;
export const POST = withRequestGuard(createChatHandler(), {
bearerToken: process.env.AI_ROUTE_TOKEN,
rateLimit: { windowMs: 60_000, max: 20 },
});Each factory accepts { model, apiKey, maxOutputTokens }. The text-action and completion handlers also take maxPromptChars — built prompts above it are rejected (defaults: 100 000, and 20 000 for completions) — and actions, extra prompt templates keyed by action id (see Prompts live on the server). The chat handler additionally takes:
system— extra product-voice instructions appended to the built-in prompt.models— an allowlist of model IDs the client may select per request via the body’smodelfield: the first entry is the default, and a requested model outside the list is rejected with a 400.promptCaching(defaulttrue) — marks the stable prompt prefix (house rules, yoursystem, attachments) with an Anthropiccache_controlbreakpoint, so a multi-turn conversation over a large PDF bills the document once per cache TTL instead of once per turn. The document outline travels after the breakpoint as an uncached context message: it changes with every edit, and anything volatile inside the cached prefix would re-bill the whole prefix — PDFs included — per edit.attachmentCache— upload dedup: attachments are hashed client-side, and once the server confirms holding a hash for the conversation, later requests (including every tool round) send a reference instead of re-uploading the bytes. The cache is in-memory and best-effort by design — a miss (cold start, eviction, another instance) answersattachments-missingand the client automatically re-sends in full, so nothing is ever lost. Options{ maxBytes, ttlMs }(defaults 64 MiB, 30 min), orfalseto disable.
All three handlers also take maxBodyBytes — oversized POSTs are rejected with 413 before the body is buffered (the schema caps only run once it’s in memory; defaults are sized per route) — and timeoutMs, which aborts a hung provider call and answers 504 (req.signal only covers the client hanging up; defaults: 60s text action, 15s completion, 5 min chat streaming, 0 disables). The text-action handlers also take maxRetries — provider retries on 429/5xx, which wait out retry-after and so must fit inside the deadline (default 1 — one wait fits the 60s deadline; 0 for the completion handler). A provider 429/503/529 is passed through with its retry-after; on the chat route, whose errors arrive mid-stream, it becomes the stream’s error text (“Try again in N seconds”).
The chat request’s mode: "ask" restricts the declared tool set to read-only tools — the gate is server-side, client-side filtering is only UX. Messages may only carry the user and assistant roles; the system prompt is the server’s alone. Defaults:
| Handler | Default model | Why |
|---|---|---|
createChatHandler | claude-sonnet-5 | Tool use and document reasoning need quality |
createTextActionHandler | claude-sonnet-5 | Short rewrites, quality-sensitive |
createCompletionHandler | claude-haiku-4-5 | Fires constantly while typing — fast and cheap, 64-token cap |
2. Client transports
createHttpGenerateText turns a route URL into the GenerateText transport the client features consume. It POSTs { actionId, target, before?, after?, params? } and reads back { text }; a non-2xx response throws an AiTransportError carrying status and retryAfter (seconds; both Retry-After forms are read, clamped to 300), which is how the autocomplete plugin backs off on an overloaded — or repeatedly failing — backend instead of caching an empty completion. A hung request is aborted after timeoutMs (default 60s, 0 disables):
import { createHttpGenerateText } from "@stesura/ai";
export const generateAiText = createHttpGenerateText("/api/ai/textAction");
export const generateAiCompletion = createHttpGenerateText("/api/ai/completion");If the routes are guarded with a bearerToken, every client transport must send it: pass { headers: { authorization: "Bearer …" } } as createHttpGenerateText’s second argument, and the same object as chatHeaders on the UI extension (it reaches the chat transport). Remember that anything a browser client sends is public — a static token gates casual abuse, not determined attackers.
3. Autocomplete plugin
import { autocompletePlugin } from "@stesura/ai";
import { requestNodeRemeasure } from "@stesura/pagination";
const plugins = [
// ...your standard plugins...
autocompletePlugin({
generate: generateAiCompletion,
// Only needed with pagination: ghost text changes a block's height
// without a transaction, so ask pagination to re-measure it.
onGhostTextChange: (view, nodeId) => requestNodeRemeasure(view.state, [nodeId]),
}),
];Pause typing to request a completion; the suggestion renders as ghost text. Tab accepts, an edit in its block or Escape dismisses — edits elsewhere (a collaborator typing in another paragraph) leave it in place. Tuning options: debounceMs (600), minPrefixChars (8), contextBlocks (2 preceding blocks; 0 sends only the current one), isEnabled, maxCacheEntries (300).
4. UI extension
createAiUIExtension adds the AI dropdown to the text menu and — when chatApi is set — the chat right panel plus its Home-tab toolbar toggle:
import { createAiUIExtension } from "@stesura/ai-react";
const aiUIExtension = createAiUIExtension({
generate: generateAiText,
chatApi: "/api/ai/chat",
// Mirror the route's `models` allowlist; first entry is the default.
chatModels: [
{ id: "claude-sonnet-5", label: "Sonnet", description: "Balanced default" },
{ id: "claude-opus-5", label: "Opus", description: "Deep multi-block edits" },
],
});
<StesuraEditor
// ...
uiExtensions={[aiUIExtension]}
/>Options (CreateAiUIExtensionOptions):
| Option | Purpose |
|---|---|
generate | GenerateText transport for text actions (required) |
chatApi | Chat route URL. Omit to ship text actions without the chat panel. |
chatHeaders | Extra headers sent with every chat request (e.g. the route guard’s bearer token). The text-action/autocomplete transports carry theirs via createHttpGenerateText’s init. |
chatModels | Models offered in the composer picker ({ id, label, description? }[]). Omit to render no picker. Mirror the server route’s models allowlist. |
chatPersist | Persist the conversation in localStorage (default true) |
chatStorageKey | Distinct storage key per document to keep conversations separate |
user | Suggestion author shown on tracked changes; defaults to the AI identity |
languages | Languages offered in the translate submenu, as { value, label }[] — value is sent to the model (keep it an English name), label is shown. Defaults to five languages labelled in the UI locale. |
The chat runtime lives in an always-mounted host, not in the panel: closing the panel or switching to another one keeps the conversation, pending reviews, and mode/model selection alive — the panel is only a viewport.
Text actions
Available from the text-menu AI dropdown; each runs runTextAction on the current selection (or block) and applies the result as tracked suggestions:
| Action | Params |
|---|---|
rephrase | — |
changeStyle | tone (e.g. "professional") |
translate | language |
simplify | — |
continueWriting | — |
runTextAction resolves with { status: "applied", warnings }, { status: "stale" } (the target blocks changed while generating — nothing applied), { status: "empty", reason }, or { status: "failed", aborted, error } — it never throws, so transport failures and aborts come back as a result to branch on. warnings lists what the markdown conversion lost (see Markdown bridge). reason is "no-target" (the selection isn’t in the document body — headers/footers, notes, select-all), "no-content" (blank target), "no-output" (the model returned nothing usable) or "no-change" (the answer matches the current text).
Edits elsewhere in the document while a text action is generating — a collaborator typing, say — don’t discard its result: the target is re-found by block id and the answer lands if the target itself is unchanged. This needs block ids (the uniqueId plugin); without them any document change returns stale.
Document chat
The chat is a document assistant with client-executed tools. The model never receives the whole document and never mutates anything directly: it reads via read_document (windowed markdown with stable {#id} block tags, see Markdown bridge) and edits by referencing those ids. Tool calls stream back to the client, execute against the live editor, and every content edit is dispatched as a track-changes suggestion — even when the user has track changes turned off.
The tool loop is budgeted: after ~24 tool calls in a single user turn the chat stops auto-resubmitting results, shows a “paused — send a message to continue” notice in the thread, and waits for the user — so a looping model (or a document whose text tries to talk it into one) cannot burn requests unattended.
Current tools:
| Tool | Purpose |
|---|---|
read_document | Outline + windowed markdown with {#id} tags; pages with fromId (or fromIndex when a boundary block has no id) |
replace_blocks | Rewrite a consecutive block range (minimal diff) |
insert_blocks | New content after a block / at document end |
delete_blocks | Deletion-marked removal |
set_style | Paragraph style only, text untouched |
set_numbering | Apply/remove numbering, set level |
upsert_list_config | Define/update a named numbering scheme |
Ask / Edit mode
The composer carries an Ask / Edit toggle (persisted per user). In ask mode the server declares only read tools to the model — it can answer questions about the document but cannot modify it; the client additionally declines any edit tool call that arrives after a mid-stream switch. Edit mode is the default.
Model picker
With chatModels configured, a picker in the composer lets the user choose the model for
the next message (disabled while a run is streaming). The selection is validated
server-side against the route’s models allowlist and persists per user.
Reviewing AI edits
Every edit tool call renders a chip in the conversation once its tracked suggestions land: Accept / Reject resolve exactly that call’s edit (scoped by the suggestion’s track-changes id — other pending AI edits are untouched), and the eye button scrolls the document to it. The panel header offers accept-all / reject-all across every pending AI suggestion. Reviewing in the document with the standard track-changes UI works too — the chips are a remote control, not a separate mechanism.
upsert_list_config is different: a list-config change carries no reviewable content
(track changes cannot represent it), so it renders a confirmation card instead — the tool
executes only when the user clicks Apply; Skip reports a decline to the model.
Attachments
The panel accepts reference documents sent with every request:
- DOCX — converted to markdown client-side (via the DOCX importer), up to 50 MB per file, and sent as fenced reference text in a user-role message (never the system prompt — attachment text carries no system authority).
- PDF — passed through to the model’s native PDF support (visual reading).
All attachments share one size budget (~25 MB of PDF): each file is checked against what is left of it before it is read. At most 8 attachments per conversation, counting files still converting.
Attachment bytes upload once per conversation, not once per request: see the chat handler’s attachmentCache option.
History persistence
Conversations persist in localStorage and restore on the next visit. Use a distinct key
per document, or turn persistence off, via the extension options:
createAiUIExtension({ /* ... */ chatStorageKey: `ai-chat-${docId}` });
createAiUIExtension({ /* ... */ chatPersist: false });The chat UI is translated in all supported editor locales — see i18n.
Markdown bridge
The model reads and writes markdown, never ProseMirror JSON. @stesura/markdown does the conversion: serializeBlocks on the way out, parseMarkdown on the way back. Neither throws. Anything that can’t be converted is simplified or dropped and reported as a Warning ({ code, message }). These warnings end up in runTextAction’s result and in the tool results the model reads, so it can correct itself.
There are two profiles:
- plain: CommonMark + GFM. Used for text actions and DOCX attachments.
- read: adds the attributes below so the model can address blocks and keep what markdown can’t express. Used by
read_document.
The parser accepts the full syntax whichever profile produced the input, so a custom text action’s answer can use it too.
| Syntax | Meaning |
|---|---|
Text {#id style=<styleId>} | Block attributes, trailing on paragraphs and headings. Code blocks take them in the fence info string; tables and horizontal rules in a <!-- {…} --> line just above. Only values that differ from what the markdown already implies are printed. |
num=ref, num=ref:level, restart=true | Numbering. Markdown lists map to numbered paragraphs: the ref sits on the first item, nesting gives the level. |
header=false | A table whose first row is data. |
width height maxWidth aspectRatio align wrap decorative=true | Image attributes, on an image that stands on its own line. |
{{page}}, {{pagebreak}}, {{xref target=<id> to=… text="…"}} | Page number, page break (on its own line), cross-reference. |
[^ref], [^ref]: … | Footnote and endnote anchors and bodies. A definition only updates a note that already exists; markdown can’t create one. |
$$…$$ | Math, inline and display. A single $ is plain text. |
 | Embedded image data over 2 KB, abbreviated. The image survives as long as the model leaves the value unchanged. |
Lossy by design, each with a warning:
- Marks other than bold, italic, strikethrough, links and inline code.
- Merged cells, and cells holding more than one paragraph.
- Raw HTML (kept as text) and blockquotes (unwrapped).
- Task-list checkboxes, list start numbers and link titles.
- Images inside a line of text (only the alt text is kept).
- Embedded files, which are written as a link.
Securing the routes
withRequestGuard is a floor, not a substitute for real auth:
| Option | Behavior |
|---|---|
bearerToken | Static token checked against Authorization: Bearer <token>, in constant time |
allowAnonymous | Serve without authentication. Required to omit bearerToken — the guard throws otherwise |
getClientKey | Rate-limit key for an unauthenticated request. Defaults to the x-forwarded-for / x-real-ip chain |
rateLimit | Fixed-window in-memory limit (windowMs, max) |
withRequestGuard throws at construction unless you set bearerToken or opt out with allowAnonymous: true: these routes spend your model budget, so a missing token must never quietly mean “open proxy”.
Failed authentication attempts count against the rate limit, so a token cannot be brute-forced at wire speed. An authenticated request is limited per token rather than per client header — the default header chain is only meaningful behind a trusted proxy that overwrites it, and keying authenticated traffic on a forgeable value would hand an abuser a fresh window per forged value. A single shared token therefore shares one window; issue per-user tokens (or supply getClientKey) for per-user limits.
The rate limit is per-process — on serverless or multi-instance deploys each instance counts separately. Use an edge or Redis-backed limiter for hard guarantees, and wrap the handlers in your own auth middleware.
Prompts live on the server
The wire carries an action id and data fragments, never prompt text:
// POST /api/ai/textAction
{ "actionId": "translate", "target": "…", "before": "…", "params": { "language": "French" } }The handler owns the template for each id and interpolates the fragments into it. A body that carries its own system string is ignored, and an unknown actionId is a 400 — otherwise the route would be a general-purpose completion proxy billed to you.
Add your own actions server-side:
export const POST = withRequestGuard(
createTextActionHandler({
actions: {
legalese: (params, payload) => ({
system: "You rewrite text in formal legal register. Return only markdown.",
prompt: fenceData("target", payload.target),
}),
},
}),
{ bearerToken: process.env.AI_ROUTE_TOKEN }
);fenceData(tag, content) from @stesura/ai is how every built-in template wraps untrusted content: it escapes any occurrence of the block’s own closing tag before wrapping, so document text carrying </target> cannot end its fence early and have the rest read as instructions. Use it for any fragment you interpolate.
Note the wire’s schema strips everything it doesn’t know: a custom action receives only target/before/after plus the built-in params fields (tone, language, instructions — all length-capped). There is deliberately no free-form params channel — arbitrary client data flowing into a prompt template is the proxy problem again in miniature. Bake variant behavior into distinct action ids instead (legalese, legalese-brief, …).
Diagnostics
The AI packages report operational problems (failed requests, degraded conversions, the tool-budget trip) through one seam instead of bare console calls. Route them to your own sink once, process-wide:
import { setAiLogger } from "@stesura/ai";
setAiLogger({
warn: (message, detail) => log.warn({ detail }, message),
error: (message, detail) => Sentry.captureMessage(message, { extra: { detail } }),
});Works on both sides — call it in server startup for the route handlers and in client bootstrap for the editor features. Default is console.
Extending the chat with custom tools
aiChatTools in @stesura/ai is the single source of truth: the server derives the model-facing declarations from it, the client validates and executes from it, and the React layer registers status chips from it. Define a tool with defineAiChatTool (zod input schema, a kind, and an executor that returns a string and dispatches edits via dispatchAsSuggestion), register it in the registry, and all three layers pick it up.
Note that the registry is a source-level construct: adding a tool means adding it to aiChatTools in packages/ai — there is currently no runtime injection point for tools defined outside the workspace (unlike text actions, which take server-side actions).
The kind drives both the ask-mode gate and the chat UI: "read" tools stay available in ask mode and render status text; "edit" tools are excluded in ask mode and render the accept/reject review chip; "config" tools (direct, non-tracked document configuration) are excluded in ask mode and render the pre-execution confirmation card. See packages/ai/README.md for the full guide and executor rules.
Next steps
- UI Extensions — how
uiExtensionsbundles work. - Plugins — plugin composition order.
- Track changes — the suggestion model AI edits flow through.