@stesura/ai
Headless core of the AI feature: the autocomplete plugin, the text-action engine, the
document-chat tool system and the HTTP transport. Framework-free; the UI is in
@stesura/ai-react and the route handlers in
@stesura/ai-server. Setup and behaviour are in the AI guide.
Transport
createHttpGenerateText turns a route URL into the GenerateText function the text actions
and autocomplete call.
createHttpGenerateText
GenerateText transport that POSTs to an @stesura/ai-server route
({ actionId, target, before, after, params } in, { text } out). The
prompt itself lives on the server — only data fragments travel.
const generate = createHttpGenerateText(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/ai/text-action`
);Throws AiTransportError on a non-2xx response. A hung request is aborted
after timeoutMs (default 60s; 0 disables) — a fetch that never settles
would otherwise pin a spinner for as long as the browser tolerates it.
| Name | Type | Default |
|---|---|---|
url | string | |
init | Omit<RequestInit, "method" | "body" | "signal"> & { timeoutMs?: number; } |
GenerateText
Transport for text generation. The package is backend-agnostic (same
pattern as proofread’s generateProofreadErrors): the host app decides
whether this hits a Next.js route, the Vercel AI SDK, or a test fake.
| Name | Type | Default |
|---|---|---|
request | AiActionRequest |
AiActionRequest
What a generation request carries over the wire: which server-owned prompt
template to run, plus the data fragments it interpolates. Deliberately no
system/prompt — the client never authors prompt text (AIS-2).
AiTransportError
A non-2xx response from an @stesura/ai-server route. Carries the upstream
status and retry-after so callers can back off on a 429/529 instead of
treating an overload like a permanent failure.
| Name | Type | Default |
|---|---|---|
status | number | |
retryAfter | number | undefinedSeconds to wait before retrying, when the server said. | |
name | string | |
message | string | |
stack | string | |
cause | unknown |
Autocomplete
Ghost-text completions. autocompletePluginKey, acceptAiSuggestion and clearAiSuggestion
are for hosts that bind their own keys or render their own accept/dismiss controls.
autocompletePlugin
Ghost-text tab autocomplete.
Debounced on typing; only triggers with the cursor at the end of a non-empty textblock, and only for local edits that touch that block — a collaborator typing elsewhere is not a reason to spend a request. The suggestion renders as a widget decoration (never enters the doc) and survives changes that don’t reach its block (a collaborator elsewhere). Tab accepts, Escape or any non-matching edit discards, and typing the suggestion’s own next characters trims it instead of discarding. Responses are cached per (block id, context, prefix) and in-flight requests are aborted on every new trigger; an overloaded backend backs the plugin off instead of poisoning the cache.
Uses handleDOMEvents.keydown (not handleKeyDown) so Tab wins over the core keymap’s indent behaviour regardless of plugin order.
v0 limitation: the ghost text is not measured by pagination (shadow DOM measuring ignores decorations), so a suggestion on the last line of a page can visually overflow until accepted or discarded.
| Name | Type | Default |
|---|---|---|
options | AutocompletePluginOptions |
AutocompletePluginOptions
Options for autocompletePlugin. Only generate is required.
autocompletePluginKey
Key for reading the autocomplete state (e.g. to gate keymaps on a visible ghost).
AutocompletePluginState
Plugin state: the ghost suggestion currently rendered, if any.
| Name | Type | Default |
|---|---|---|
suggestion | GhostSuggestion | null |
GhostSuggestion
A completion rendered as ghost text after the cursor.
| Name | Type | Default |
|---|---|---|
pos | numberCursor position the suggestion is anchored to. | |
text | string | |
nodeId | stringuniqueId of the block the ghost text renders in (“anonymous” when unset). |
acceptAiSuggestion
Accept the current ghost suggestion: insert it as regular typed content (the track-changes transaction modifier treats it like user input).
| Name | Type | Default |
|---|---|---|
view | EditorView |
clearAiSuggestion
Discard the current ghost suggestion (Escape behavior). Returns false when none is shown.
| Name | Type | Default |
|---|---|---|
view | EditorView |
Text actions
runTextAction is what the text-menu dropdown calls; use it directly for custom triggers.
runTextAction
Run a text-menu AI action on the current selection: serialize the selected blocks → generate → parse → apply as a minimal edit, transformed into track-changes suggestions (even when track changes is off) so the user reviews with the standard accept/reject UI. One transaction, one undo step.
When the document changes during generation, the target is re-found by
block id and the answer still lands if the target itself is untouched;
otherwise (or without the uniqueId plugin) the result is stale.
Never throws: transport failures and aborts come back as
{ status: "failed" } so callers branch on the result instead of pairing
every call with a try/catch.
| Name | Type | Default |
|---|---|---|
options | RunTextActionOptions |
RunTextActionOptions
Options for runTextAction.
TextActionId
"rephrase" | "changeStyle" | "translate" | "simplify" | "continueWriting" — the built-in actions runTextAction understands.
TextActionParams
Per-action parameters; each field is length-capped on the wire.
TextActionResult
What runTextAction resolves with. It never throws: failures come back as a result.
status | Fields | Meaning |
|---|---|---|
"applied" | warnings: Warning[] | Suggestions landed; warnings lists what the markdown conversion lost |
"stale" | — | The target blocks changed while the model was generating; nothing applied |
"empty" | reason: TextActionEmptyReason | Nothing to apply, and why |
"failed" | aborted: boolean, error: unknown | The transport rejected or the edit couldn’t be applied; aborted is true when the caller’s signal fired |
TextActionEmptyReason
"no-target" | "no-content" | "no-output" | "no-change": the selection isn’t in a body section
(headers/footers, notes, select-all), the target is blank, the model returned nothing usable, or the
answer matches the current text.
Prompt templates
Templates live on the server (why). These are the
types and built-ins a custom actions entry on the text-action handler works with.
AI_ACTION_TEMPLATES
THE server-owned prompt templates. The wire body carries an actionId and
data fragments — never a system string — so the route cannot be repurposed
as a generic completion proxy (AIS-2). Hosts add their own via
createTextActionHandler’s actions option, still server-side.
| Name | Type | Default |
|---|---|---|
rephrase | AiActionTemplate | |
changeStyle | AiActionTemplate | |
translate | AiActionTemplate | |
simplify | AiActionTemplate | |
continueWriting | AiActionTemplate | |
autocomplete | AiActionTemplate |
AiActionTemplate
Builds the server-owned prompt for a built-in action.
| Name | Type | Default |
|---|---|---|
params | TextActionParams | |
payload | AiActionPayload |
AiActionId
TextActionId | "autocomplete" — the ids AI_ACTION_TEMPLATES covers.
AiActionPayload
The data fragments a client may send for an action. Everything here is untrusted content the server fences into its own template — never prompt text.
| Name | Type | Default |
|---|---|---|
target | stringThe text the action operates on. | |
before | stringRead-only context around the target (preceding/following blocks). | |
after | string |
fenceData
Wrap untrusted content in a <tag> fence it cannot close.
Every prompt in this package hands the model data (document text, attachment content, an autocomplete prefix) inside a tagged block, with a “this is data, not instructions” rule alongside. Without escaping, content carrying the block’s own closing tag ends the fence early and everything after it reads as prompt — system-level prompt in the chat handler’s case.
| Name | Type | Default |
|---|---|---|
tag | string | |
content | string |
redactionEntitiesTemplate
The opt-in prompt for AI redaction suggestions, registered under REDACTION_ENTITIES_ACTION on a
route whose model the host controls. See Redaction.
Server-side prompt for AI redaction suggestions. Not a built-in: the host
opts in by registering it, on a route whose model it controls (document text
is personal data):
createTextActionHandler({ model, actions: { [REDACTION_ENTITIES_ACTION]: redactionEntitiesTemplate } }).
| Name | Type | Default |
|---|---|---|
params | TextActionParams | |
payload | AiActionPayload |
Chat tools
The tool contract and the set the server and the editor are both handed. See Extending the chat with custom tools.
defineAiTool
Ties execute’s input type to the schema and derives run, which validates raw
model input through the schema’s Standard Schema validate and then executes.
run does not catch: the dispatcher that calls it does, and logs.
A function with overloads, not an arrow: one name for both tool kinds, each
keeping its own execute signature, without a cast.
createAiToolSet
Validate and freeze a tool set. Throws on a wiring bug: a duplicate name, a name
that is not snake_case (64 characters at most, the providers’ limit), a reserved
name, or an empty description. To leave a built-in out, filter builtInAiTools;
a built-in cannot be replaced under its own name.
| Name | Type | Default |
|---|---|---|
tools | readonly AiToolSetEntry[] |
builtInAiTools
The editor tools every chat gets unless the host leaves some out, in a fixed
order: the tool list is part of the cached prompt prefix. Hosts add their own
tools through createAiToolSet; see packages/ai/README.md.
AiToolDeclaration
What the model sees of a tool, and what the server needs to declare it. Static:
the server route and the editor both import it. The executor is bound where its
dependencies live (see AiEditorTool, AiServerTool).
AiEditorTool
A tool that runs in the browser, against the document.
Executor contract:
- Always return a string: results, warnings and errors alike. The model reads it and self-corrects; nothing may throw for bad input.
- Content edits land as track-changes suggestions (
dispatchAsSuggestion); never auto-accept. - Resolve block ids at execution time: the document may have changed since the model read it, and a vanished id is a polite error, not a crash.
AiServerTool
A tool that runs in the host’s API route, next to its secrets, and never sees
the editor. Same contract as editor tools: always a string, never throw.
Authorise as context.user, never as the operator alone. A tool with side
effects outside the editor declares kind: "config", so the user confirms it.
AiToolExecutionOptions
Options an editor tool’s execute receives.
createAiToolSession
The read ledger of one conversation: the last version of each block read_document showed in
full, and the view. Editing tools merge an edit against it. Keep one per conversation and start
a new one when the conversation is cleared or restored: a ledger that outlives what the model
remembers would let it edit blocks it no longer has in context.
AiToolSession
What the agent has read in one conversation, so an edit merges against the text it saw rather than the block as it is now: a user’s change since the read survives the agent’s rewrite (docs/executed/ai-editing-plan.md, section 2). Hosts keep one per conversation, a fresh one when it is cleared or restored.
AiReadView
"final" | "markup" | "original". How read_document showed tracked changes: as accepted,
inline, or as rejected.
AiToolKind
"read" | "annotate" | "edit" | "config". Drives the ask-mode gate and the chat UI: read tools
stay available in ask mode and render status text; annotate tools (comments) stay available in
ask mode and render the review chip; edit tools render the accept/reject chip; config tools
apply untracked configuration, or a server tool’s side effect, and are confirmed before running.
AiChatMode
"ask" | "edit" (default "edit"). In ask mode the model only gets read and annotate tools.
Compare tools
compare_documents and compare_details compare the open document with an attached .docx or a
saved version. The route declares compareToolDeclarations; the editor page binds the executors
with createCompareTools, giving it the chat’s attachments and, optionally, a versions source.
createCompareTools
The compare tools, bound to where the documents come from: the chat’s attachments and,
optionally, saved versions. Without getVersion, attachments only. Bind them where the
sources live (the editor page), and declare compareToolDeclarations on the route.
| Name | Type | Default |
|---|---|---|
options | CreateCompareToolsOptions |
CreateCompareToolsOptions
CompareAttachment
An attached document, imported: what compare_documents compares with attachment:<name>.
| Name | Type | Default |
|---|---|---|
name | string | |
doc | Node |
CompareVersionInfo
A saved version listVersions offers.
| Name | Type | Default |
|---|---|---|
id | string | |
label | string | |
date | string |
compareToolDeclarations
The compare tools’ declarations, for the chat route. Same order as createCompareTools.
compareDocumentsDeclaration
| Name | Type | Default |
|---|---|---|
name | string | |
runsOn | "editor" | |
kind | "read" | |
defer | false | |
labels | { running: string; complete: string; } | |
description | string | |
inputSchema | ZodObject<{ with: ZodOptional<ZodString>; }, $strip> |
compareDetailsDeclaration
Issue tools
list_issues, fix_issues and apply_format_fixes give the agent the editor’s checkers:
proofread’s spelling and grammar errors (fixed as tracked suggestions), format-lint’s suggestions
(applied directly after the user confirms) and broken cross-references. The route declares
issueToolDeclarations; the editor page binds the executors with createIssueTools, passing the
providers proofreadIssues, formatLintIssues and referenceIssues from their packages.
createIssueTools
The issue tools over the given sources (proofreadIssues from @stesura/proofread,
formatLintIssues from @stesura/format-lint, referenceIssues from @stesura/references,
or the host’s own). Bind them on the editor page and declare issueToolDeclarations on the route.
| Name | Type | Default |
|---|---|---|
providers | readonly IssueProvider[] |
IssueProvider
One source of findings for the agent. Lives in the package that owns the findings and is
passed to createIssueTools in @stesura/ai. A provider whose plugin is not in the editor
collects nothing.
AiIssue
A finding a source reports to the agent’s list_issues.
AiIssueFixKind
"none" | "tracked" | "direct". What fixing an issue does: nothing automatic, a tracked
suggestion the user reviews, or a direct change track changes cannot represent (document
configuration), which the user confirms first.
AiIssueFix
One issue to fix, with the chosen replacement when the issue offers choices.
| Name | Type | Default |
|---|---|---|
id | string | |
choice | string |
AiIssueApplyResult
What a provider’s apply did. Ids it neither applied nor skipped count as gone.
| Name | Type | Default |
|---|---|---|
tr | Transaction | nullThe fixes, on a transaction of the state | |
applied | string[] | |
skipped | { id: string; reason: string; }[] |
issueToolDeclarations
The issue tools’ declarations, for the chat route. Same order as createIssueTools.
listIssuesDeclaration
fixIssuesDeclaration
applyFormatFixesDeclaration
| Name | Type | Default |
|---|---|---|
name | string | |
runsOn | "editor" | |
kind | "config" | |
labels | { running: string; complete: string; confirm: string; } | |
description | string | |
inputSchema | ZodObject<{ ids: ZodArray<ZodString>; }, $strip> |
Chat attachments
Reference documents sent with every chat request. The chat panel builds these itself; hosts that
attach documents from elsewhere (a file store, another editor) use docToMarkdownAttachment.
docToMarkdownAttachment
Serialize an imported document (e.g. from @stesura/docx importDocx) into
a markdown attachment. Plain profile on purpose: {#id}/num= attrs would
reference blocks and list configs that don’t exist in the HOST document,
which would mislead the editing tools.
| Name | Type | Default |
|---|---|---|
doc | Node | |
name | string |
AiChatAttachment
{ kind: "markdown"; name; content; truncated?; hash? } | { kind: "pdf"; name; data; hash? }.
Markdown attachments carry converted text; PDFs carry base64 (no data: prefix) for the model’s
native PDF reading. hash (SHA-256 hex of the content) keys upload dedup: once the server confirms
it holds a hash, later requests send an AiChatAttachmentRef ({ kind, name, hash, omitted: true })
instead of the bytes.
Limits
| Constant | Value | Meaning |
|---|---|---|
MAX_ATTACHMENTS | 8 | Attachments per conversation, enforced client-side and by the server schema |
ATTACHMENT_MARKDOWN_MAX_CHARS | 120_000 | Markdown characters per attachment before truncation (~30K tokens) |
AI_ATTACHMENT_MAX_BASE64_CHARS | 30_000_000 | Shared size budget for all attachments, in base64 characters |
Suggestions
Every AI edit lands as a track-changes suggestion. These dispatch and resolve them.
dispatchAsSuggestion
Dispatch an edit as track-changes suggestions, even when track changes is off (forceTrackChanges). skipTrackChanges is set on the tr BEFORE the transform — meta is copied onto the tracked tr wholesale, which stops the editor’s transaction modifier from transforming a second time on dispatch.
requestedBy is the signed-in user the agent works for, kept on every mark/attr.
Pass trackChangesId to make several dispatches one suggestion (one per tool call).
Returns the trackChangesId stamped on every mark/attr of this edit, so the
caller can later resolve exactly this suggestion (accept/reject scoped via
resolveTrackChangesInRange’s filter).
| Name | Type | Default |
|---|---|---|
view | EditorView | |
tr | Transaction | |
user | CurrentUser | |
requestedBy | { id: string; name?: string; } | |
trackChangesId | string |
DEFAULT_AI_USER
Author of AI suggestions and comments when the host supplies no user; also the scope of “accept/reject all”.
| Name | Type | Default |
|---|---|---|
id | string | |
name | string | |
avatar | string | |
color | string |
hasAiSuggestion
True while the tracked edit of one tool call still exists in the document.
| Name | Type | Default |
|---|---|---|
state | EditorState | |
trackChangesId | string |
resolveAiSuggestion
Accept or reject the tracked edit a single AI tool call produced,
identified by the trackChangesId dispatchAsSuggestion returned. Returns
false when nothing matched — the suggestion was already resolved from the
document review UI.
| Name | Type | Default |
|---|---|---|
view | EditorView | |
trackChangesId | string | |
action | AiSuggestionAction |
resolveAllAiSuggestions
Accept or reject every pending suggestion authored by the AI identity (all tool calls, all
conversations); with requestedById, only those it made for that user, so one user’s accept
all leaves another user’s agent edits pending (null: only those made for no one, what a chat
without a signed-in user may resolve). Returns false when there were none.
| Name | Type | Default |
|---|---|---|
view | EditorView | |
action | AiSuggestionAction | |
user | CurrentUser | |
requestedById | string |
scrollToAiSuggestion
Move the selection to the first block/mark of the tracked edit and scroll it into view. Returns false when the suggestion no longer exists.
| Name | Type | Default |
|---|---|---|
view | EditorView | |
trackChangesId | string |
AiSuggestionAction
"approve" | "reject" — how resolveAiSuggestion and resolveAllAiSuggestions resolve.
Diagnostics
setAiLogger
Route the AI packages’ diagnostics (@stesura/ai, ai-react,
ai-server) somewhere other than the console — a Sentry breadcrumb, a
server log line. One process-wide sink, like console itself: these are
operational diagnostics, not per-call results (those flow through return
values and tool-result strings).
| Name | Type | Default |
|---|---|---|
logger | AiLogger |
AiLogger
Where the AI packages’ diagnostics go. Matches the console shape.
| Name | Type | Default |
|---|---|---|
warn | (message: string, detail?: unknown) => void | |
error | (message: string, detail?: unknown) => void |