diff --git a/.github/workflows/ci-docs.yml b/.github/workflows/ci-docs.yml index cc676fba6d..39a19f5316 100644 --- a/.github/workflows/ci-docs.yml +++ b/.github/workflows/ci-docs.yml @@ -51,3 +51,12 @@ jobs: - name: Test Code Examples run: pnpm --filter @superdoc/docs test:examples + + - name: Build SDK (Node) + run: pnpm --prefix packages/sdk/langs/node run build + + - name: Build CLI + run: pnpm --prefix apps/cli run build + + - name: Check AI Snippets + run: pnpm --filter @superdoc/docs check:ai-snippets diff --git a/apps/docs/ai/agents/architecture.mdx b/apps/docs/ai/agents/architecture.mdx new file mode 100644 index 0000000000..93b135eeed --- /dev/null +++ b/apps/docs/ai/agents/architecture.mdx @@ -0,0 +1,137 @@ +--- +title: How it works +sidebarTitle: How it works +description: "The moving parts behind SuperDoc's LLM tools: your agent loop, the SDK, the CLI host, and the model โ€” and exactly what crosses each boundary" +keywords: "superdoc architecture, sdk cli host, agent loop, llm tool dispatch, document sessions, json-rpc" +--- + +Three layers cooperate to let a model edit Word documents. Nothing here is required reading to get started โ€” the [overview quick start](/ai/agents/llm-tools#quick-start) works without it โ€” but when you're debugging, budgeting tokens, or deciding where code should run, this is the map. + +## The big picture + +```mermaid +flowchart TB + subgraph browser["๐Ÿ–ฅ Browser (optional)"] + UI["Your chat UI"] + ED["SuperDoc editor
superdoc / @superdoc-dev/react
renders the same .docx for the user"] + end + + subgraph backend["โš™๏ธ Your backend"] + direction TB + APP["Your agent loop
the broker โ€” owns messages, calls the model,
forwards tool calls, streams status
"] + subgraph sdkbox["@superdoc-dev/sdk (Node) ยท superdoc-sdk (Python)"] + KIT["createAgentToolkit()
tools ยท systemPrompt ยท dispatch"] + CLIENT["Document client
open / save / close sessions"] + end + subgraph clibox["SuperDoc CLI host (bundled binary)"] + HOST["JSON-RPC host process"] + ENGINE["Headless document engine
the same engine the browser editor uses"] + SESS["Sessions
live documents + revision counters"] + end + end + + subgraph provider["โ˜๏ธ LLM provider"] + LLM["OpenAI ยท Anthropic ยท Vercel AI"] + end + + UI -- "user request" --> APP + APP -- "1 ยท tools + system prompt" --> KIT + APP -- "2 ยท messages + tools" --> LLM + LLM -- "3 ยท tool calls" --> APP + APP -- "4 ยท dispatch(doc, name, args)" --> KIT + KIT -- "typed operations" --> HOST + CLIENT -- "spawn + sessions" --> HOST + HOST --- ENGINE + ENGINE --- SESS + HOST -- "5 ยท receipts" --> APP + APP -- "status + final summary" --> UI +``` + +The three rules this diagram encodes: + +1. **The SDK is server-side.** `dispatchSuperDocTool` (and the toolkit's `dispatch`) need a session-bound document handle from `createSuperDocClient().open(...)`. Never import the SDK โ€” or the `superdoc` editor package โ€” into browser bundles or Next.js API-route bundling without marking it external. +2. **The model never touches a document.** It only ever sees tool definitions, a system prompt, and tool results. Your loop is the broker for everything. +3. **The engine is the same everywhere.** The CLI host embeds the identical document engine the browser editor uses โ€” edits made headless render exactly the same in the editor. + +## SDK โ†” CLI: where documents actually live + +The SDK packages are deliberately thin: typed clients, the tool surfaces, and prompts. The document engine ships inside the **CLI host binary** (`@superdoc-dev/cli-` for Node, an embedded companion in the Python wheels). + +```mermaid +sequenceDiagram + autonumber + participant SDK as SDK client + participant CLI as CLI host process + participant ENG as Document engine + + SDK->>CLI: spawn (bundled binary), JSON-RPC over stdio + CLI-->>SDK: host.capabilities (handshake) + SDK->>CLI: open({doc: "contract.docx"}) + CLI->>ENG: load .docx into a live session + CLI-->>SDK: session handle (revision 0) + Note over SDK,CLI: every operation now targets this session + SDK->>CLI: blocks.list / comments.create / mutations.apply ... + CLI->>ENG: execute against the live document + CLI-->>SDK: result + revision bump when mutated + SDK->>CLI: save({out: "reviewed.docx"}) + CLI-->>SDK: bytes written + SDK->>CLI: close() +``` + +What crosses this boundary, precisely: + +- **Transport**: newline-delimited JSON-RPC on stdio. One host process serves many sequential requests; sessions keep documents live between calls, which is what makes multi-step agent edits fast. +- **Validation**: every operation's input is validated against the generated contract *inside the host* before it touches the document โ€” a malformed tool call fails loudly with a coded error, never half-applies. +- **Revisions**: the host keeps a per-session revision counter (starts at 0, +1 per mutation). `--expected-revision` / optimistic-concurrency guards compare against this counter. Receipts additionally carry the engine's own revision string for before/after evidence. +- **Tracked changes**: `changeMode: "tracked"` rides the operation into the engine, which records real OOXML revisions (`w:ins`, `w:del`, `w:pPrChange`) โ€” the same marks Word shows. + +If the host can't start, everything surfaces as `Host process disconnected` โ€” the [troubleshooting checklist](/ai/agents/llm-tools#troubleshooting-host-process-disconnected) walks the causes (Gatekeeper, Node version, bundler). + +## LLM โ†” SDK: one tool call, end to end + +The model's entire world is `tools` + `systemPrompt` + tool results. Here's a complete round trip for "rewrite the termination clause as a tracked change" on the core preset: + +```mermaid +sequenceDiagram + autonumber + participant App as Your agent loop + participant LLM as Model + participant SDK as Toolkit dispatch + participant CLI as CLI host + + App->>SDK: createAgentToolkit({provider, preset: "core"}) + SDK-->>App: tools (2) + systemPrompt + dispatch + App->>LLM: system + user message + tools + LLM-->>App: tool call ยท superdoc_inspect {includeDomains: ["blocks"]} + App->>SDK: dispatch(doc, "superdoc_inspect", args) + SDK->>CLI: read operations (session) + CLI-->>SDK: snapshot (blocks, 1-based ordinals, nodeIds) + SDK-->>App: snapshot โ†’ tool result + App->>LLM: tool result + LLM-->>App: tool call ยท superdoc_perform_action
{action: "rewrite_block", selector, text, changeMode: "tracked"} + App->>SDK: dispatch(doc, "superdoc_perform_action", args) + Note over SDK,CLI: resolve target โ†’ validate args โ†’ mutate โ†’ re-inspect โ†’ verify + CLI-->>SDK: receipt {status: "ok", verification, pre/post evidence} + SDK-->>App: receipt โ†’ tool result + App->>LLM: receipt + LLM-->>App: final text: "Rewrote clause 8.2 as a tracked change." + App->>CLI: doc.save({out: "reviewed.docx"}) +``` + +Three properties worth internalizing: + +- **The static prefix repeats every turn.** Tools + system prompt are re-sent on each model call and every tool result stays in history โ€” this is why the [token budget](/ai/agents/llm-tools#token-budget) section exists, and why Anthropic callers should enable prompt caching. +- **Receipts are the feedback loop.** The model self-corrects from `status`, `verification`, and `errors[].message` โ€” which is why core-preset receipts carry evidence rather than a bare "ok", and why streaming them to your UI gives users meaningful progress for free. +- **Dispatch is the security boundary you control.** The toolkit's pre-bound `dispatch` enforces the preset and `excludeActions` no matter what the model asks for. + +## Where Python and MCP fit + +- **Python** is the same architecture with the same binary: `superdoc-sdk` talks to the CLI host embedded in its platform wheel (or `SUPERDOC_CLI_BIN`). The core preset's tools, prompt, and dispatch are proxied through the host, so both languages expose byte-identical surfaces. +- **MCP** is an alternative front door for MCP clients (Claude Desktop, IDEs): the `superdoc-mcp` server embeds the document engine in-process and registers either preset's tools (`MCP_PRESET=core`) plus session lifecycle tools. Same engine, same actions โ€” no agent loop of your own required. + +## Related + +- [Overview & agent loops](/ai/agents/llm-tools) +- [Core preset reference](/ai/agents/core-preset) +- [Legacy preset reference](/ai/agents/legacy-preset) +- [Document API](/document-api/overview) โ€” the operation contract the SDK speaks diff --git a/apps/docs/ai/agents/best-practices.mdx b/apps/docs/ai/agents/best-practices.mdx index 13cbd278a3..ac67f0cbce 100644 --- a/apps/docs/ai/agents/best-practices.mdx +++ b/apps/docs/ai/agents/best-practices.mdx @@ -1,206 +1,162 @@ --- title: Best practices sidebarTitle: Best practices -description: "Get better results from LLM document editing: prompting, tool call patterns, and workflow tips" +description: "Get better results from LLM document editing: prompting, tool call patterns, and workflow tips for both presets" keywords: "llm best practices, ai document editing, prompt engineering, superdoc tools, tool calling, document automation" --- -These patterns help your LLM agent produce reliable, efficient document edits. +These patterns help your LLM agent produce reliable, efficient document edits. The first group applies to every integration; the rest is split by preset โ€” [core](/ai/agents/core-preset) (recommended) and [legacy](/ai/agents/legacy-preset) work differently enough that their playbooks are separate. -## Use the bundled system prompt +## Practices for every integration -`getSystemPrompt()` returns a tested prompt that teaches the model how to use SuperDoc tools: targeting, workflow order, and multi-action tools. Load it once and pass it as the system message. +### Use the bundled system prompt โ€” from the same preset as your tools + +Each preset ships the prompt it was designed and evaluated with. Load both through the toolkit so they can never mismatch: ```typescript -import { getSystemPrompt } from '@superdoc-dev/sdk'; +import { createAgentToolkit } from '@superdoc-dev/sdk'; -const systemPrompt = await getSystemPrompt(); -// Pass as the system message in your LLM call +const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', +}); ``` -You can extend it with task-specific instructions. Append your own rules after the bundled prompt: +Extend it with task-specific rules rather than replacing it: ```typescript -const systemPrompt = await getSystemPrompt(); -const fullPrompt = `${systemPrompt}\n\n## Additional rules\n- Use tracked changes for all edits.\n- Always search before editing.`; +const fullPrompt = `${systemPrompt}\n\n## Additional rules\n- Use tracked changes for all edits.\n- End with a one-sentence summary of what changed.`; ``` -Or start from scratch with something like this: - -````markdown -You edit `.docx` files using SuperDoc intent tools. Be efficient and minimize tool calls. - -## Workflow - -1. **Read**: Use `superdoc_get_content` to understand the document. -2. **Search**: Use `superdoc_search` to find stable handles or block addresses. -3. **Edit**: Use the focused tool that matches the job: - - `superdoc_edit` for insert, replace, delete, undo, redo - - `superdoc_format` for inline or paragraph formatting - - `superdoc_create` for paragraphs and headings - - `superdoc_comment` for comment threads - - `superdoc_track_changes` for review decisions -4. **Batch only when useful**: Use `superdoc_mutations` for preview/apply or atomic multi-step edits. +### Feed errors back -## Rules +Dispatch failures are written for the model to act on. Pass them back as tool results โ€” most models self-correct on the next turn: -- Search before mutating so targets come from fresh results. -- Use focused intent tools for normal edits. -- Use `superdoc_mutations` when you need an atomic batch or preview/apply flow. -- Set `changeMode: "tracked"` when edits need human review. -- Feed tool errors back so you can recover. -```` +```typescript +try { + const result = await dispatch(doc, call.function.name, JSON.parse(call.function.arguments)); + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); +} catch (err: any) { + // Return the error as a tool result: the model will see it and adjust + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify({ error: err.message }) }); +} +``` -## Read first, search, then edit +### Cache tools and prompts -A typical edit takes 3-5 tool calls: +Tools and the system prompt don't change between requests. Build the toolkit once at startup and reuse it across conversations. On Anthropic, also pass `cache: true` so the tool array carries prompt-caching markers (see [token budget](/ai/agents/llm-tools#token-budget)). -1. `superdoc_get_content`: understand what's in the document -2. `superdoc_search`: find the exact location (returns stable handles/addresses) -3. Edit tool (`superdoc_edit`, `superdoc_format`, etc.): apply the change using targets from search +```typescript +let kit: Awaited> | null = null; -This matters because handles from search results point to the exact right location. If the model guesses a block address instead of searching for it, edits land in the wrong place. +async function ensureToolkit() { + kit ??= await createAgentToolkit({ provider: 'openai', preset: 'core' }); + return kit; +} +``` -## Minimize tool calls +### Use tracked changes for review workflows -Instruct the LLM to plan all edits before calling tools. A well-structured prompt like "Find the termination clause and rewrite it to allow 30-day notice" should take 3-5 calls, not 15. +Set `changeMode: "tracked"` (or instruct it in your appended prompt rules): every AI edit lands as a tracked change users can accept or reject in SuperDoc or Microsoft Word. A "suggest vs. apply directly" toggle in your UI maps 1:1 to this flag. -Batch multiple changes only when atomic execution is genuinely helpful: use `superdoc_mutations` for that. +### Add examples for repeatable workflows -## Prefer markdown insert for multi-block creation +If the same kind of edit runs across many documents, include a concrete tool call example in your system prompt. Models that see a working example of the exact invocation produce correct calls more reliably than models that only see the schema. -When you need to create multiple headings and paragraphs in one operation, use `superdoc_edit` with `type: "markdown"` instead of calling `superdoc_create` once per block. A single markdown insert produces the entire structure in one call. +### Pin your model version -```json -{ - "action": "insert", - "type": "markdown", - "value": "## Executive Summary\n\nThis agreement governs the terms of service.\n\n## Key Provisions\n\nThe following provisions apply to all parties." -} -``` +Use a specific model ID rather than an alias. Aliases can change behavior between releases and break working tool call patterns. -After inserting, apply formatting in a single `superdoc_mutations` batch using `format.apply` steps: one step per block or range. This reduces a workflow that might otherwise take 40+ calls down to 4: read, search, insert, format. +## Core preset (recommended) -## Use focused tools: `superdoc_mutations` is an escape hatch +The core surface is two tools โ€” `superdoc_inspect` and `superdoc_perform_action` โ€” with 40 named actions that resolve their own targets and return receipts. The playbook follows from that. -For straightforward edits, use the focused intent tools (`superdoc_edit`, `superdoc_format`, `superdoc_create`, `superdoc_list`, `superdoc_comment`). They validate arguments, give clear errors, and are easier for models to call correctly. +### Inspect narrowly, then act -Reach for `superdoc_mutations` only when you need: -- Preview/apply semantics (show what will change before committing) -- Atomic multi-step edits (all-or-nothing batch) -- A workflow that would otherwise require refreshing targets between steps +A typical edit is 2โ€“3 calls: one narrow inspect, one action, and (only when the receipt says so) a follow-up. Steer the model away from full-document reads: -## Feed errors back +- `{countsOnly: true}` for orientation; `includeDomains` to fetch only what the task needs. +- On large documents, window with `blockOffset`/`blockLimit` โ€” ordinals are absolute, so windows line up. -`dispatchSuperDocTool` returns structured errors. Pass them back as tool results: most models self-correct on the next turn. +### Trust receipts, not vibes -```typescript -try { - const result = await dispatchSuperDocTool(doc, toolCall.function.name, JSON.parse(toolCall.function.arguments)); - messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result) }); -} catch (err: any) { - // Return the error as a tool result: the model will see it and adjust - messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify({ error: err.message }) }); -} -``` +Every action returns real pre/post evidence. Teach your loop (and your users) to read it: -## Choose formatting values from the document +- `status: "ok"` with `verificationPassed: true` โ€” done; don't re-inspect "to be sure." +- `status: "partial"` โ€” some of the work landed; the receipt says which part. Re-inspect, then fix forward. +- `status: "failed"` โ€” nothing changed unless the receipt explicitly says otherwise. `errors[].message` usually contains the exact recovery step, and `recovery`/`revertHint` are machine-usable. -Don't hardcode formatting values. Read them from the document's existing content and match what's already there. +### Batch with selectors, not with repeated calls -**Body text:** Read `fontFamily`, `fontSize`, and `color` from non-empty paragraphs with `alignment: "justify"` or `alignment: "left"`. Set `bold: false` for body paragraphs. +Actions absorb batching: `add_comments` takes `selectors[]` for many blocks in one call; `replace_text` takes `edits[]`; `format_text` takes `targetTexts[]`. One action call with a batch argument beats N calls every time โ€” cheaper, atomic, one receipt. -Many DOCX documents report `underline: true` on all blocks due to style inheritance. This is a DOCX artifact: not intentional formatting. Do not carry it forward when inserting new paragraphs. +### Narrow the surface for your product -**Headings:** Read from existing heading blocks in the document. Scale `fontSize` up relative to body text. Headings are typically bold and sometimes centered: confirm against what's already in the document rather than assuming. +If your product should never delete tables or fill placeholders, exclude those actions โ€” [`excludeActions`](/ai/agents/core-preset#narrowing-the-surface-excludeactions) removes them from the enum, the prompt, and dispatch in one move. A smaller surface is also cheaper per turn. -```typescript -// Get content first, find a representative body paragraph -const content = await superdoc.getContent(); -const bodyParagraph = content.blocks.find( - (b) => b.type === 'paragraph' && b.text?.trim().length > 0 -); -const { fontFamily, fontSize, color } = bodyParagraph?.formatting ?? {}; - -// Use those values when formatting inserted content -``` +### Prompt examples -## Add examples for repeatable workflows +Tested against the core action surface โ€” use as inspiration or few-shot examples: -If the same kind of edit runs across many documents (e.g., always rewriting a specific clause, always adding a comment to a section), include a concrete tool call example in your system prompt. Models that see a working example of the exact tool invocation produce correct calls more reliably than models that only see the schema. +- "Find the termination clause and rewrite it to require 30-day written notice. Use tracked changes." +- "Replace all references to 'Contractor' with 'Service Provider' as tracked changes." +- "Add a comment to every paragraph that mentions personally identifiable information: 'Verify PII handling.'" +- "Number the unnumbered obligation at the end of section 2 like its siblings." +- "Accept all formatting revisions but leave text edits pending review." +- "Move the PREAMBLE section after SCHEDULE A." +- "Add a 2ร—3 table under the second heading with headers Owner and Stage, then style it." -## Use tracked changes for review workflows +## Legacy preset -Add `changeMode: "tracked"` to edit tool calls, or instruct the model via the system prompt: +The legacy surface is low-level: the model searches for handles, then edits by address. If you're on it, these patterns matter โ€” and [migrating to core](/ai/agents/legacy-preset#migrating-to-core) removes most of them. -``` -Use tracked changes for all edits so a human can review them. -``` +### Read first, search, then edit -This way every AI edit appears as a tracked change that users can accept or reject in SuperDoc or Microsoft Word. +A typical edit takes 3-5 tool calls: -## Pin your model version +1. `superdoc_get_content`: understand what's in the document +2. `superdoc_search`: find the exact location (returns stable handles/addresses) +3. Edit tool (`superdoc_edit`, `superdoc_format`, etc.): apply the change using targets from search -Use a specific model ID (e.g., `gpt-4.1` or `claude-sonnet-4-6`) rather than an alias like `gpt-4o`. Aliases can change behavior between releases and break working tool call patterns. +Handles from search results point to the exact right location. If the model guesses a block address instead of searching, edits land in the wrong place. **Search again after every mutation** โ€” refs expire when the revision bumps. -## Cache tools and prompts +### Prefer markdown insert for multi-block creation -Tools and the system prompt don't change between requests. Load them once at startup and reuse across all conversations. +When creating multiple headings and paragraphs, use `superdoc_edit` with `type: "markdown"` instead of one `superdoc_create` per block: -```typescript -let cachedTools: any[] | null = null; -let cachedSystemPrompt: string | null = null; - -async function ensureToolsLoaded() { - if (!cachedTools) { - const result = await chooseTools({ provider: 'openai' }); - cachedTools = result.tools; - } - if (!cachedSystemPrompt) { - cachedSystemPrompt = await getSystemPrompt(); - } - return { tools: cachedTools, systemPrompt: cachedSystemPrompt }; +```json +{ + "action": "insert", + "type": "markdown", + "value": "## Executive Summary\n\nThis agreement governs the terms of service.\n\n## Key Provisions\n\nThe following provisions apply to all parties." } ``` -## Prompt examples +After inserting, apply formatting in a single `superdoc_mutations` batch using `format.apply` steps โ€” one step per block or range. This reduces a workflow that might otherwise take 40+ calls down to 4: read, search, insert, format. -These prompts have been tested against the SuperDoc tool set. Use them as inspiration for your own workflows, or include them as few-shot examples in your system prompt. +### Use focused tools; `superdoc_mutations` is an escape hatch -### Document review +For straightforward edits, use the focused intent tools โ€” they validate arguments and give clear errors. Reach for `superdoc_mutations` only when you need preview/apply semantics, an atomic multi-step batch, or a workflow that would otherwise require refreshing targets between steps (it resolves all targets before any step executes). -- "Find the termination clause and rewrite it to require 30-day written notice. Use tracked changes." -- "Apply yellow highlight to every sentence that contains an indemnification obligation." -- "Replace all references to 'Contractor' with 'Service Provider' and make each replacement italic with tracked changes enabled." -- "Underline every sentence that references payment terms or late fees." -- "Insert CONFIDENTIAL: DO NOT DISTRIBUTE at the very top of the document and make it bold, red, 14pt." -- "Scan the document for inconsistent capitalization of defined terms and fix them with tracked changes enabled." +### Choose formatting values from the document -### Formatting and structure +Don't hardcode formatting values โ€” read them from existing content and match: -- "Format the entire document in Times New Roman, 12-point." -- "Make all Heading 2 paragraphs bold and set them to 14-point font." -- "Keep each section heading with the paragraph that follows it so they don't split across pages." -- "Remove all extra blank paragraphs and convert all double spaces after periods to single spaces." -- "Right-align all section headings." +- **Body text**: read `fontFamily`, `fontSize`, `color` from non-empty paragraphs; set `bold: false` for body. +- Many DOCX documents report `underline: true` on all blocks due to style inheritance โ€” a DOCX artifact, not intentional formatting. Don't carry it forward. +- **Headings**: read from existing heading blocks; confirm bold/centering against the document rather than assuming. -### Content generation and editing +### Prompt examples -- "Add a new heading 'Learning Objectives' at the top, followed by a bullet list with 3 key takeaways from the document content." -- "Read the document and add a heading 'Executive Summary' at the end, followed by a one-paragraph summary and a bullet list of the 5 key provisions." -- "Find the governing law section and insert a new paragraph after it: 'Any disputes arising under this Agreement shall be resolved through binding arbitration.'" -- "Find all paragraphs that mention 'personally identifiable information' and add a comment: 'Verify PII handling complies with current data retention policy.'" -- "Convert the list of references at the end into a numbered list and restart numbering at 1." - -### Search and replace - -- "Rewrite all dates in this document in the format January 1, 2026." +- "Format the entire document in Times New Roman, 12-point." +- "Make all Heading 2 paragraphs bold and set them to 14-point font." - "Replace every occurrence of 'FY2024' with 'FY2025' throughout the document." -- "Add the ยง symbol before every section number reference." +- "Insert CONFIDENTIAL: DO NOT DISTRIBUTE at the very top, bold, red, 14pt." +- "Convert the list of references at the end into a numbered list and restart numbering at 1." ## Related -- [LLM tools](/ai/agents/llm-tools): tool catalog and SDK functions +- [Core preset reference](/ai/agents/core-preset) ยท [Legacy preset reference](/ai/agents/legacy-preset) - [How to use](/ai/agents/integrations): step-by-step integration guide - [Debugging](/ai/agents/debugging): troubleshoot tool call failures -- [Document API](/document-api/overview): the operation set behind the tools +- [How it works](/ai/agents/architecture): SDK โ†” CLI โ†” LLM mechanics diff --git a/apps/docs/ai/agents/core-preset.mdx b/apps/docs/ai/agents/core-preset.mdx new file mode 100644 index 0000000000..d695396ed4 --- /dev/null +++ b/apps/docs/ai/agents/core-preset.mdx @@ -0,0 +1,369 @@ +--- +title: Core preset โ€” the action surface +sidebarTitle: Core preset (recommended) +description: "Two tools, forty deterministic actions: inspect a document, then edit it with named, verifiable operations โ€” including tracked-changes redlining." +keywords: "superdoc core preset, superdoc_perform_action, superdoc_inspect, llm document actions, tracked changes ai, redlining, deterministic document editing" +--- + +The `core` preset is an **actions-only LLM surface**: instead of many low-level tools, the model gets exactly two โ€” + +| Tool | Role | +| --- | --- | +| `superdoc_inspect` | Read: a deterministic snapshot of the document (blocks, lists, tables, comments, tracked changes, โ€ฆ) | +| `superdoc_perform_action` | Write: one of **40 named actions** with flat, validated arguments and a verifiable receipt | + +Every action wraps the underlying [Document API](/document-api/overview) operations with product semantics: it resolves targets deterministically, applies the edit, re-inspects the document, and returns a **receipt** with real pre/post evidence โ€” so your agent loop (and your users) can trust what actually happened. + +The default preset is still `legacy` (the grouped intent tools documented in the [overview](/ai/agents/llm-tools)). The core preset is opt-in: pass `preset: 'core'` everywhere. + +## Quick start + + + + ```typescript + import { createSuperDocClient, createAgentToolkit, type AgentReceipt } from '@superdoc-dev/sdk'; + + const client = createSuperDocClient(); + await client.connect(); + const doc = await client.open({ doc: './contract.docx' }); + + // One call โ€” tools, system prompt, and a pre-bound dispatcher that are + // guaranteed to agree on preset and exclusions. + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', + }); + + // ... run your agent loop (see the overview page) ... + const receipt = (await dispatch(doc, 'superdoc_perform_action', { + action: 'replace_text', + edits: [{ find: 'thirty (30) days', replace: 'sixty (60) days' }], + changeMode: 'tracked', + })) as AgentReceipt; + + console.log(receipt.status, receipt.verificationPassed); + ``` + + + ```python + from superdoc import SuperDocClient, create_agent_toolkit + + client = SuperDocClient() + client.connect() + doc = client.open({"doc": "./contract.docx"}) + + # One call โ€” tools, system prompt, and pre-bound dispatchers that are + # guaranteed to agree on preset and exclusions. + kit = create_agent_toolkit({"provider": "openai", "preset": "core"}) + tools, system_prompt = kit["tools"], kit["system_prompt"] + + receipt = kit["dispatch"]( + doc, + "superdoc_perform_action", + { + "action": "replace_text", + "edits": [{"find": "thirty (30) days", "replace": "sixty (60) days"}], + "changeMode": "tracked", + }, + ) + print(receipt["status"], receipt.get("verificationPassed")) + ``` + + + + +Tools, system prompt, and dispatch must all come from the **same preset** โ€” the legacy dispatcher does not know `superdoc_perform_action` and fails with `Unknown tool`. `createAgentToolkit` guarantees this; if you use the standalone functions (`chooseTools`, `getSystemPrompt`, `dispatchSuperDocTool`) instead, pass the same `preset` (and `excludeActions`) to every call. + + +## Reading: `superdoc_inspect` + +`superdoc_inspect` returns a stable snapshot the model can target edits against. Prefer the **narrowest** inspect that answers the question: + +```jsonc +// counts only โ€” cheapest possible orientation call +{ "countsOnly": true } + +// just the lists and tables +{ "includeDomains": ["lists", "tables"] } + +// only headings +{ "includeDomains": ["blocks"], "blockNodeTypes": ["heading"] } +``` + +Available domains: `blocks`, `lists`, `tables`, `comments`, `trackedChanges`, `sections`, `headerFooters`, `styles`, `contentControls`, `fields`, `hyperlinks`, `bookmarks`, `permissionRanges`, `images`. + +### Large documents: windowed reads + +For long documents, read blocks in contiguous windows instead of one giant snapshot. Ordinals are **absolute**, so windows line up across calls: + +```jsonc +{ "includeDomains": ["blocks"], "blockOffset": 0, "blockLimit": 200 } +{ "includeDomains": ["blocks"], "blockOffset": 200, "blockLimit": 200, "omitEmptyBlocks": true, "dropTextPreview": true } +``` + +`omitEmptyBlocks` and `dropTextPreview` trim the payload for a pure reading pass; `blockTextLimit` caps per-block text length. This keeps a single inspect call from dominating your context window (every tool result lives in conversation history and is re-billed as prompt tokens on every later turn). + + +All ordinals shown by `superdoc_inspect` (`blockOrdinal`, `paragraphOrdinal`, `headingOrdinal`, `tableOrdinal`, โ€ฆ) are **1-based**, and selectors accept the same 1-based values. + + +## Editing: `superdoc_perform_action` + +One tool, one `action` argument, flat parameters. The dispatcher statically validates arguments against the action's declared schema (unknown keys are rejected with a descriptive error) before anything touches the document. + +### Targeting: selectors + +Actions that operate on a specific block accept a `selector`: + +| Selector | Shape | Use when | +| --- | --- | --- | +| Node id | `{kind:"nodeId", nodeId}` | You have a `nodeId` from `superdoc_inspect` (most precise) | +| Ordinal | `{kind:"ordinal", ordinalKind:"paragraphOrdinal"\|"headingOrdinal"\|"tableOrdinal"\|"listOrdinal"\|"sectionOrdinal"\|"bodyParagraphOrdinal"\|"blockOrdinal", value:N}` | "the 3rd paragraph", "the 2nd table" (1-based) | +| Text search | `{kind:"textSearch", terms:[...], match?:"all"\|"any", occurrence?:N, nodeTypes?:[...]}` | You know the text but not the position | +| Table cell | `{kind:"tableCell", tableOrdinal, rowIndex, columnIndex}` | Cell-scoped edits | +| Relative | `{kind:"relative", position:"after"\|"before", target:selector}` | "the paragraph after the heading X" | + +### Placement + +Insert-style actions accept a `placement`: + +```jsonc +{ "at": "document_end" } +{ "at": "document_start" } +{ "at": "after", "selector": { "kind": "textSearch", "terms": ["Definitions"] } } +{ "at": "before", "selector": { "kind": "nodeId", "nodeId": "p42" } } +``` + +### Tracked changes: `changeMode` + +Most mutating actions accept `changeMode: "tracked"`. In tracked mode the edit is recorded as a **redline suggestion** โ€” a tracked insert/delete/format change the user (or the model) can accept or reject later โ€” instead of being applied directly. This is the backbone of review workflows: + + + + ```jsonc + { + "action": "rewrite_block", + "selector": { "kind": "ordinal", "ordinalKind": "paragraphOrdinal", "value": 4 }, + "text": "Either party may terminate this Agreement on sixty (60) days' written notice.", + "changeMode": "tracked" + } + ``` + + + ```jsonc + { + "action": "rewrite_block", + "selector": { "kind": "ordinal", "ordinalKind": "paragraphOrdinal", "value": 4 }, + "text": "Either party may terminate this Agreement on sixty (60) days' written notice." + } + ``` + + + +A "suggest vs. apply directly" toggle in your chat UI maps 1:1 to setting `changeMode` on every mutating call. Review then happens with the same action surface: + +- `accept_tracked_changes` / `reject_tracked_changes` โ€” optionally filtered by `author` or `changeType` (`insert` | `delete` | `replacement` | `format`) +- `undo_changes` / `redo_changes` โ€” deterministic history recovery (`untilMarker` restores until a rendered clause marker like `"2.1."` reappears) + +A few actions are **always direct** (not tracked) and say so in their reference entry: `move_range`, `split_list`, `set_paragraph_spacing`, `insert_page_break`, `add_hyperlink`, `style_table`. Requesting `changeMode:"tracked"` on `move_range` fails with nothing changed โ€” use `move_text` for tracked text-span moves. + +### Receipts + +Every action returns a receipt โ€” not just "ok", but evidence: + +```jsonc +{ + "status": "ok", // "ok" | "partial" | "failed" + "intent": "replace_text", + "preSnapshot": { "revision": "41", "counts": { "paragraphs": 58, "trackedChanges": 0 } }, + "postSnapshot": { "revision": "42", "counts": { "paragraphs": 58, "trackedChanges": 2 } }, + "verification": [ + { "check": { "kind": "text-replaced", "find": "thirty (30) days" }, "passed": true } + ], + "verificationPassed": true, + "editsApplied": 2 +} +``` + +What to rely on: + +- **`status`** โ€” `partial` means some of the requested work landed (the receipt says which part); `failed` means nothing changed unless the receipt explicitly says otherwise. +- **`verification`** โ€” post-edit checks the action ran against a fresh snapshot (counts deltas, placement adjacency, text presence). `verificationPassed` is the roll-up. +- **`errors[]`** โ€” failures carry a `code`, a human-readable `message` written for the model to act on, and often a structured `recovery` (`{kind: "reinspect" | "retry" | "revert", call?}`) plus a paste-ready `revertHint`. +- **`formattingMatched`** โ€” insert actions that blend new content into its surroundings (e.g. `add_list_items`) report the font/size/style they copied from neighbors, so the model knows not to re-format. +- **List caps** โ€” long per-item lists (`executedOperations`, `selectedTargets`) are capped at 8 entries with a `*Count` field preserving the true total, to keep receipts from bloating your conversation history. + +### Action reference + +Forty actions, grouped. Arguments marked `?` are optional; most mutating actions also accept `changeMode`. + +#### Text & structure + +| Action | Arguments | Notes | +| --- | --- | --- | +| `insert_paragraphs` | `texts[]` (or `text`), `placement?`, `headingLevel?` | First item can become a heading (1โ€“6) | +| `insert_heading` | `text`, `level`, `placement?` | | +| `replace_text` | `edits[{find, replace}]`, `selector?`, `caseSensitive?` | Selector scopes all edits to one block | +| `delete_text` | `finds[]`, `selector?`, `caseSensitive?` | Scope with `selector` to delete stray whitespace safely | +| `append_list` | `items[]`, `kind?: ordered\|bullet`, `headingText?`, `placement?` | With `placement`, builds the list at that block instead of document end | +| `create_table` | `rows`, `columns`, `cellTexts?`, `placement?` | Tracked mode makes the whole insertion one tracked change | +| `rewrite_block` | `selector`, `text` | Replaces a block's text; tracked mode produces a redline | +| `fill_placeholders` | `values[]` and/or `fields[{label?, value}]` | Fills template placeholders | +| `move_range` | `fromText`, `toText?`, `afterText` or `beforeText` | Moves a block range or visual "section" identified by text. Auto-extends to the section end when `toText` is omitted. Direct-only: `changeMode:"tracked"` is refused with nothing changed (use `move_text` for tracked moves). Also refuses reversed ranges and ranges containing tables/lists/images โ€” move those with `move_table` or narrow the range | + +#### Lists & numbering + +| Action | Arguments | Notes | +| --- | --- | --- | +| `convert_list` | `kind`, one of: `listOrdinal?`/`anchorText?`, `fromMarker`+`toMarker`, `fromText`+`toText` | Converts lists, rendered clause ranges ("2.1."โ€“"2.3."), or plain paragraphs to a list **in place** | +| `attach_numbering` | `anchorText` or `nodeId`, `likeMarker` | Numbers a block at the same scheme/level as the sibling rendering `likeMarker` (e.g. `"10."`); tracked mode records the former state | +| `add_list_items` | `anchorText` + `entries[{text, level?}]` | **The** way to add items into an existing list. `level` is relative to the anchor (0 same, positive nests, negative promotes โ€” e.g. `-1` from item "12(e)" creates top-level item 13). Inherits the anchor's look automatically | +| `split_list` | `anchorText`, `restartNumbering?` (default true) | Splits one list into two at an item. Direct edit | + +#### History + +| Action | Arguments | Notes | +| --- | --- | --- | +| `undo_changes` | `untilMarker?`, `steps?` (1โ€“25) | Steps history back until the marker reappears โ€” deterministic revert | +| `redo_changes` | `steps?` (default 1) | Re-applies edits after an undo overshot. Only valid before any new edit | + +#### Moving text + +| Action | Arguments | Notes | +| --- | --- | --- | +| `move_text` | `text`, `afterText?` | Direct by default (requires `afterText`). `changeMode:"tracked"` records the move as a redline: tracked delete at the source + tracked insert at the destination โ€” accept keeps the move, reject restores the original order | + +#### Comments + +| Action | Arguments | Notes | +| --- | --- | --- | +| `comment_paragraphs` | `commentText`, `scope?: all\|body`, `excludeBlockQuotes?` | One comment per paragraph | +| `add_comments` | `commentText`, `selector` or `selectors[]` | Batch many targets into `selectors[]` in ONE call | +| `resolve_comments` | `anchorText?`, `reopen?` | Omit `anchorText` to resolve all open comments | +| `reply_to_comment` | `commentText`, `anchorText` or `commentId` | Adds a **threaded** reply, not a new top-level comment | + +#### Tracked-change review + +| Action | Arguments | Notes | +| --- | --- | --- | +| `accept_tracked_changes` | `author?`, `changeType?` | e.g. `changeType:"format"` accepts only formatting revisions | +| `reject_tracked_changes` | `author?`, `changeType?` | | + +#### Formatting + +| Action | Arguments | Notes | +| --- | --- | --- | +| `format_text` | `bold?`/`italic?`/`underline?`/`strike?`, `highlight?`, `color?`, `fontSize?`, `targetText`/`targetTexts[]`/`selector`, `caseSensitive?` | Applies to every occurrence; tracked-safe | +| `apply_style` | `selector`, one of `styleId`, `headingLevel`, `likeText` | `likeText` copies another block's style **and** effective look | +| `format_paragraph` | `selector`, `alignment` | Tracked mode records the former alignment (`w:pPrChange`) | +| `set_paragraph_spacing` | `selector`, `lineSpacing?`, `spaceBefore?`, `spaceAfter?` | Never insert blank paragraphs for spacing. Direct edit | +| `normalize_body_font_size` | `fontSize` | Whole-body font size | +| `set_font_family` | `fontFamily`, `selector?` or `targetText`/`targetTexts[]?` | Omit both to set the whole body typeface | +| `apply_letter_spacing` | `selector`, `letterSpacing` | | + +#### Layout, links, media + +| Action | Arguments | Notes | +| --- | --- | --- | +| `insert_page_break` | `selector` | Sets `pageBreakBefore` on the block โ€” never pushes content with empty paragraphs. Direct edit | +| `add_hyperlink` | `text`, `url`, `tooltip?` | Turns existing text into a link. Direct edit | +| `insert_toc` | `title?`, `placement?` | Table of contents | + +#### Tables + +| Action | Arguments | Notes | +| --- | --- | --- | +| `style_table` | `tableOrdinal?`, `accentColor?` | Professional look in one call: accent header, bold labels, banded rows. Direct edit | +| `move_table` | `tableOrdinal?`, `placement` | Moves the whole table with all content in one call | +| `delete_table` | `tableOrdinal?` | Deletes an entire table | +| `insert_table_row` | `tableOrdinal?`, `rowIndex?`, `position?`, `cellTexts?`, `dryRun?` | | +| `insert_table_column` | `tableOrdinal?`, `columnIndex?`, `position?`, `headerText?` | | +| `delete_table_row` | `tableOrdinal?`, `rowIndex` | | +| `delete_table_column` | `tableOrdinal?`, `columnIndex` | | +| `split_table` | `tableOrdinal?`, `rowIndex`, `separatorText?` | | + +## Narrowing the surface: `excludeActions` + +Hide actions you don't want the model to see. The exclusion narrows **everything coherently**: the `action` enum, the argument schema, the per-action documentation lines in the system prompt, and the dispatch guard (an excluded action is refused even if the model guesses its name). + +The safest way to use it is `createAgentToolkit` โ€” one options object, all three surfaces guaranteed to agree: + +```typescript +const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'anthropic', + preset: 'core', + excludeActions: ['delete_table', 'fill_placeholders'], +}); +``` + +With the standalone functions, pass the same list everywhere: + + + + ```typescript + const { tools } = await chooseTools({ + provider: 'anthropic', + preset: 'core', + excludeActions: ['delete_table', 'fill_placeholders'], + }); + const prompt = await getSystemPrompt('core', { excludeActions: ['delete_table', 'fill_placeholders'] }); + + // Defense in depth: pass the same list at dispatch time. + await dispatchSuperDocTool(doc, name, args, { + preset: 'core', + excludeActions: ['delete_table', 'fill_placeholders'], + }); + ``` + + + ```python + tools = choose_tools({ + "provider": "anthropic", + "preset": "core", + "excludeActions": ["delete_table", "fill_placeholders"], + })["tools"] + prompt = get_system_prompt("core", exclude_actions=["delete_table", "fill_placeholders"]) + + dispatch_superdoc_tool(doc, name, args, preset="core", + exclude_actions=["delete_table", "fill_placeholders"]) + ``` + + + ```bash + superdoc preset get-tools --preset core --provider anthropic \ + --excludeActions delete_table,fill_placeholders + superdoc preset get-system-prompt --preset core \ + --excludeActions delete_table,fill_placeholders + ``` + + + +Unknown action names in the list throw immediately (typo protection). The legacy preset ignores exclusion options entirely. + +## The system prompt + +`getSystemPrompt('core')` returns the prompt the action surface was **evaluated with**: document-model vocabulary (visual sections, rendered markers, effective formatting), the full per-action argument documentation, targeting conventions, tracked-changes rules, and receipt-reading discipline (trust the receipt, re-inspect on `partial`, use `revertHint` on failures). + +- **Use it as-is** for the best out-of-the-box behavior โ€” the eval suite scores this exact prompt. +- **Extend it** by appending your domain instructions (tone, house style, what to never touch) at the end. +- **Replacing it entirely is not recommended**: the per-action lines teach argument shapes the schema alone can't convey. If you do, keep the action documentation block. + +## Creating custom actions + + +**Coming soon.** A first-class authoring kit (`defineAction`) is in the works: define your own action with a typed schema and handler, register it alongside the built-in forty, and it appears in the tool enum, the system prompt's action list, and the dispatch surface automatically โ€” same receipts, same exclusion support. Until it ships, custom capabilities can be added as [custom tools](/ai/agents/legacy-preset#creating-custom-tools) registered next to the preset's own tools in your loop. + + +## Over MCP + +The core preset is also available through the SuperDoc MCP server: start it with `MCP_PRESET=core` and clients get the session lifecycle tools (`superdoc_open` / `superdoc_save` / `superdoc_close`) plus `superdoc_inspect` and `superdoc_perform_action`, registered from the same catalog `chooseTools()` serves โ€” the MCP surface cannot drift from the SDK surface. Server instructions use the core MCP prompt automatically. + +## Experimental: `superdoc_execute_code` + +The SDK can dispatch a third tool, `superdoc_execute_code` (model-authored JavaScript against a synchronous in-process Document API). It is **work-in-progress and deliberately not advertised**: it is absent from `chooseTools()` results and from the served system prompt, and its behavior may change. It will ship behind an explicit safety flag in a future release. Don't build on it yet. + +## Related + +- [Overview & agent loops](/ai/agents/llm-tools): providers, token budget, error codes, troubleshooting +- [Best practices](/ai/agents/best-practices) +- [Document API](/document-api/overview): the operations the actions wrap diff --git a/apps/docs/ai/agents/debugging.mdx b/apps/docs/ai/agents/debugging.mdx index e811346c94..2f9fa8fb4a 100644 --- a/apps/docs/ai/agents/debugging.mdx +++ b/apps/docs/ai/agents/debugging.mdx @@ -9,7 +9,7 @@ When tool calls fail or produce unexpected results, use these patterns to diagno ## LLM tools wrap the Document API -Every LLM tool call maps to a [Document API](/document-api/overview) operation under the hood. `superdoc_edit` with `action: "replace"` calls the same function as `doc.replace()`. +Every LLM tool call maps to a [Document API](/document-api/overview) operation under the hood. On the core preset, `superdoc_perform_action` with `action: "replace_text"` resolves its targets and then runs the same operations as `doc.replace()`; on legacy, `superdoc_edit` with `action: "replace"` calls it directly. This gives you a clear debugging strategy: @@ -20,8 +20,8 @@ This gives you a clear debugging strategy: ```typescript // Instead of going through the LLM, test the operation directly: const result = await doc.replace({ - target: { handle: 'some-handle' }, - content: 'New text', + ref: 'ref-from-a-recent-search', // refs/handles come from search results + text: 'New text', }); console.log(result); // Does this work? ``` @@ -32,6 +32,10 @@ This narrows every issue to one of two layers: the operation or the prompt. Add logging around `dispatchSuperDocTool` to see exactly what the model is requesting and what comes back. + +`dispatchSuperDocTool(doc, name, args)` without options dispatches against the **legacy default**. Pass `{ preset: 'core' }` when your tools came from the core preset โ€” or use the toolkit's pre-bound `dispatch`, which can't mismatch. The examples below assume core. + + ```typescript for (const toolCall of choice.message.tool_calls) { const args = JSON.parse(toolCall.function.arguments); @@ -40,7 +44,7 @@ for (const toolCall of choice.message.tool_calls) { console.log(`[agent] tool: ${toolCall.function.name}`, JSON.stringify(args, null, 2)); try { - const result = await dispatchSuperDocTool(doc, toolCall.function.name, args); + const result = await dispatchSuperDocTool(doc, toolCall.function.name, args, { preset: 'core' }); // Log the result (truncate large responses) const resultStr = JSON.stringify(result); @@ -60,6 +64,17 @@ What to look for in logs: - **Targets**: are handles/addresses from a recent search, or did the model guess? - **Result**: did the operation return data or an error? +## Read the receipt first (core preset) + +On the core preset, most "failures" aren't thrown errors โ€” they're receipts doing their job. Before reaching for logs: + +- `status: "failed"` + `errors[].code: "MATCH_NOT_FOUND"` โ€” the target text/element wasn't found and **nothing was changed**. The message names what to fix; `recovery` is machine-usable (`reinspect` / `retry` / `revert` with a paste-ready call). +- `status: "partial"` โ€” part of the batch landed; the receipt reports which part (`editsApplied` / `editsSkipped`, capped lists with `*Count` totals). Re-inspect, then fix forward. +- `verificationPassed: false` on an `ok` receipt โ€” the edit applied but a post-check (placement adjacency, count delta) disagreed; the `verification` array shows which check. +- `INVALID_ARGUMENT` with `excluded: true` โ€” the action is excluded by your `excludeActions` configuration, not broken. + +Feeding the whole receipt back as the tool result is usually all the "debugging" the model needs. + ## Error shapes `dispatchSuperDocTool` throws errors in two categories: @@ -67,7 +82,7 @@ What to look for in logs: **Validation errors**: bad arguments before the operation runs: ```json { "error": "Missing required parameter: action" } -{ "error": "Unknown action 'bold' for tool superdoc_format. Valid actions: inline, set_style, set_alignment, set_indentation, set_spacing" } +{ "error": "Unknown action 'bold' for tool superdoc_format. Valid actions: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style" } { "error": "Parameter 'target' is required for action 'replace'" } ``` @@ -81,17 +96,35 @@ Both types are returned as strings in `err.message`. Pass them back as tool resu ## Common failure modes +Shared (either preset): + | Symptom | Cause | Fix | | --- | --- | --- | -| Model calls the wrong tool | System prompt missing or too vague | Use `getSystemPrompt()` or add workflow instructions | -| "Target not found" errors | Model uses stale or guessed handles | Instruct model to always search before editing | -| Edits land in the wrong place | Model invented a block address | Use `superdoc_search` to get fresh handles | +| Model calls the wrong tool | System prompt missing, or paired with the wrong preset's tools | Use `createAgentToolkit` so prompt and tools always match | +| `Unknown tool` on dispatch | Tools from one preset dispatched through another | Same fix โ€” one preset for tools, prompt, and dispatch | | Infinite tool call loop | Model never reaches a stopping point | Add a max iterations guard (see below) | -| Model doesn't use tools at all | Tools not passed to the API call | Verify `chooseTools()` result is in the `tools` param | +| Model doesn't use tools at all | Tools not passed to the API call | Verify the toolkit's `tools` is in the `tools` param | | "Missing required parameter" | Model forgot `action` or another field | Check the tool schema: add examples to the prompt | | Collaboration edits not appearing | SDK not in the same collab room | Verify the collaboration URL and documentId match | | Operation works via API but fails via tool | Model passes wrong argument types/names | Log the parsed arguments and compare to the API signature | +Core preset: + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `MATCH_NOT_FOUND` receipts | Target text drifted after earlier edits | Re-inspect, target current text (nothing was changed โ€” safe to retry) | +| `partial` receipts on batches | Some edits' targets matched, others didn't | Read `editsApplied`/`editsSkipped`; retry only the skipped ones | +| Action refused (`excluded: true`) | Your `excludeActions` config | Expected โ€” the guard is doing its job | +| Tracked edit landed direct | `changeMode` missing on that call | Set `changeMode: "tracked"` per call or via prompt rules | + +Legacy preset: + +| Symptom | Cause | Fix | +| --- | --- | --- | +| "Target not found" errors | Stale or guessed handles | Always search before editing; search again after every mutation | +| `REVISION_MISMATCH` | Ref fetched before a mutation, used after | Use `superdoc_mutations` for multi-block edits, or re-search between edits | +| Edits land in the wrong place | Model invented a block address | Use `superdoc_search` to get fresh handles | + ## Inspect tools directly Dump the tool schemas to verify the SDK loaded correctly: @@ -99,12 +132,12 @@ Dump the tool schemas to verify the SDK loaded correctly: ```typescript import { listTools, getToolCatalog } from '@superdoc-dev/sdk'; -// See all tools for a provider -const tools = await listTools('openai'); +// See all tools for a provider โ€” omitting the preset argument selects legacy +const tools = await listTools('openai', 'core'); console.log(JSON.stringify(tools, null, 2)); // Get the full catalog with metadata -const catalog = await getToolCatalog(); +const catalog = await getToolCatalog('core'); console.log(`Loaded ${catalog.tools.length} tools`); ``` @@ -124,7 +157,7 @@ while (iterations++ < MAX_ITERATIONS) { if (!message.tool_calls?.length) break; for (const call of message.tool_calls) { - const result = await dispatchSuperDocTool(doc, call.function.name, JSON.parse(call.function.arguments)); + const result = await dispatchSuperDocTool(doc, call.function.name, JSON.parse(call.function.arguments), { preset: 'core' }); messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); } } diff --git a/apps/docs/ai/agents/integrations.mdx b/apps/docs/ai/agents/integrations.mdx index d90092d63c..6e9dfa9e01 100644 --- a/apps/docs/ai/agents/integrations.mdx +++ b/apps/docs/ai/agents/integrations.mdx @@ -50,37 +50,47 @@ await client.connect(); const doc = await client.open({ doc: './contract.docx' }); ``` -## Step 3: Load tools and system prompt +## Step 3: Load the toolkit -Load the tool definitions for your provider and the default system prompt. Both can be cached: they don't change between requests. +One call returns the tool definitions for your provider, the matching system prompt, and a pre-bound dispatcher. This guide uses the [core preset](/ai/agents/core-preset) (recommended); everything can be cached โ€” it doesn't change between requests. ```typescript - import { chooseTools, getSystemPrompt } from '@superdoc-dev/sdk'; + import { createAgentToolkit } from '@superdoc-dev/sdk'; - const { tools } = await chooseTools({ provider: 'openai' }); - const systemPrompt = await getSystemPrompt(); + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', + }); ``` ```typescript - import { chooseTools, getSystemPrompt } from '@superdoc-dev/sdk'; + import { createAgentToolkit } from '@superdoc-dev/sdk'; - const { tools } = await chooseTools({ provider: 'anthropic' }); - const systemPrompt = await getSystemPrompt(); + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'anthropic', + preset: 'core', + }); ``` ```typescript - import { chooseTools, getSystemPrompt } from '@superdoc-dev/sdk'; + import { createAgentToolkit } from '@superdoc-dev/sdk'; - const { tools: sdkTools } = await chooseTools({ provider: 'vercel' }); - const systemPrompt = await getSystemPrompt(); + const { tools: sdkTools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'vercel', + preset: 'core', + }); ``` + +On the legacy preset instead? Pass `preset: 'legacy'` (or omit it โ€” legacy is the default for backwards compatibility). The loop below is identical; what changes is the tool surface the model sees and that results are raw operation results rather than receipts. See the [legacy preset page](/ai/agents/legacy-preset) for its behaviors. + + ## Step 4: Run the agent loop The agent loop sends messages to the LLM, dispatches tool calls, feeds results back, and repeats until the model is done. @@ -89,8 +99,7 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b ```typescript import OpenAI from 'openai'; - import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - + const openai = new OpenAI(); // uses OPENAI_API_KEY env var const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ @@ -119,8 +128,7 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b if (toolCall.type !== 'function') continue; try { - const result = await dispatchSuperDocTool( - doc, + const result = await dispatch(doc, toolCall.function.name, JSON.parse(toolCall.function.arguments), ); @@ -144,15 +152,14 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b **What's happening:** 1. The system prompt teaches the model how to use SuperDoc tools. - 2. The `while(true)` loop calls OpenAI, checks for tool calls, dispatches them via `dispatchSuperDocTool`, and feeds results back. + 2. The `while(true)` loop calls OpenAI, checks for tool calls, dispatches them via the toolkit's `dispatch`, and feeds results back. 3. When the model returns `finish_reason: 'stop'` (no more tool calls), the loop ends. 4. Errors are caught and returned as tool results so the model can see what went wrong and retry. ```typescript import Anthropic from '@anthropic-ai/sdk'; - import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - + const anthropic = new Anthropic(); // uses ANTHROPIC_API_KEY env var const messages: Anthropic.MessageParam[] = [ @@ -161,7 +168,7 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b while (true) { const response = await anthropic.messages.create({ - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', max_tokens: 4096, system: systemPrompt, messages, @@ -183,8 +190,7 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b if (block.type !== 'tool_use') continue; try { - const result = await dispatchSuperDocTool( - doc, + const result = await dispatch(doc, block.name, block.input as Record, ); @@ -220,18 +226,17 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b ```typescript import { generateText, jsonSchema, stepCountIs } from 'ai'; import { openai } from '@ai-sdk/openai'; - import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - + // Convert SDK tool definitions into Vercel AI tool objects with execute functions + // The vercel dialect is flat: { name, description, inputSchema }. const tools: Record = {}; for (const t of sdkTools as any[]) { - const fn = t.function; - tools[fn.name] = { - description: fn.description, - inputSchema: jsonSchema>(fn.parameters), + tools[t.name] = { + description: t.description, + inputSchema: jsonSchema>(t.inputSchema), execute: async (args: Record) => { try { - return await dispatchSuperDocTool(doc, fn.name, args); + return await dispatch(doc, t.name, args); } catch (err: any) { return { error: err.message }; } @@ -255,7 +260,7 @@ The agent loop sends messages to the LLM, dispatches tool calls, feeds results b **What's happening:** - 1. SDK tool definitions are converted into Vercel AI tool objects: each with an `execute` function that calls `dispatchSuperDocTool`. + 1. SDK tool definitions are converted into Vercel AI tool objects: each with an `execute` function that calls the toolkit's `dispatch`. 2. `generateText` handles the agent loop internally: it calls the model, executes tools, feeds results back, and repeats. 3. `stopWhen: stepCountIs(10)` sets a max iteration guard. 4. No manual `while(true)` loop needed: Vercel AI manages it for you. @@ -278,21 +283,18 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a ```typescript import OpenAI from 'openai'; - import { - createSuperDocClient, - chooseTools, - dispatchSuperDocTool, - getSystemPrompt, - } from '@superdoc-dev/sdk'; + import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; // 1. Open the document const client = createSuperDocClient(); await client.connect(); const doc = await client.open({ doc: './contract.docx' }); - // 2. Load tools and system prompt - const { tools } = await chooseTools({ provider: 'openai' }); - const systemPrompt = await getSystemPrompt(); + // 2. Load the toolkit (tools + system prompt + dispatch, core preset) + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', + }); // 3. Build the conversation const openai = new OpenAI(); @@ -321,8 +323,7 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a if (toolCall.type !== 'function') continue; try { - const result = await dispatchSuperDocTool( - doc, + const result = await dispatch(doc, toolCall.function.name, JSON.parse(toolCall.function.arguments), ); @@ -350,21 +351,18 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a ```typescript import Anthropic from '@anthropic-ai/sdk'; - import { - createSuperDocClient, - chooseTools, - dispatchSuperDocTool, - getSystemPrompt, - } from '@superdoc-dev/sdk'; + import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; // 1. Open the document const client = createSuperDocClient(); await client.connect(); const doc = await client.open({ doc: './contract.docx' }); - // 2. Load tools and system prompt - const { tools } = await chooseTools({ provider: 'anthropic' }); - const systemPrompt = await getSystemPrompt(); + // 2. Load the toolkit (tools + system prompt + dispatch, core preset) + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'anthropic', + preset: 'core', + }); // 3. Build the conversation const anthropic = new Anthropic(); @@ -375,7 +373,7 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a // 4. Agent loop while (true) { const response = await anthropic.messages.create({ - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', max_tokens: 4096, system: systemPrompt, messages, @@ -395,8 +393,7 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a if (block.type !== 'tool_use') continue; try { - const result = await dispatchSuperDocTool( - doc, + const result = await dispatch(doc, block.name, block.input as Record, ); @@ -428,12 +425,7 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a ```typescript import { generateText, jsonSchema, stepCountIs } from 'ai'; import { openai } from '@ai-sdk/openai'; - import { - createSuperDocClient, - chooseTools, - dispatchSuperDocTool, - getSystemPrompt, - } from '@superdoc-dev/sdk'; + import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; // 1. Open the document const client = createSuperDocClient(); @@ -441,19 +433,21 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a const doc = await client.open({ doc: './contract.docx' }); // 2. Load tools and system prompt - const { tools: sdkTools } = await chooseTools({ provider: 'vercel' }); - const systemPrompt = await getSystemPrompt(); + const { tools: sdkTools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'vercel', + preset: 'core', + }); // 3. Convert SDK tools into Vercel AI tool objects + // The vercel dialect is flat: { name, description, inputSchema }. const tools: Record = {}; for (const t of sdkTools as any[]) { - const fn = t.function; - tools[fn.name] = { - description: fn.description, - inputSchema: jsonSchema>(fn.parameters), + tools[t.name] = { + description: t.description, + inputSchema: jsonSchema>(t.inputSchema), execute: async (args: Record) => { try { - return await dispatchSuperDocTool(doc, fn.name, args); + return await dispatch(doc, t.name, args); } catch (err: any) { return { error: err.message }; } @@ -486,22 +480,25 @@ A complete, copy-pasteable script that opens a document, runs an agent, saves, a ### AWS Bedrock -Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` shape: +Use the toolkit with `provider: 'anthropic'` and convert to Bedrock's `toolSpec` shape: ```typescript import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; - import { createSuperDocClient, chooseTools, dispatchSuperDocTool } from '@superdoc-dev/sdk'; + import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; const client = createSuperDocClient(); await client.connect(); const doc = await client.open({ doc: './contract.docx' }); // Get tools in Anthropic format, convert to Bedrock toolSpec shape - const { tools } = await chooseTools({ provider: 'anthropic' }); + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'anthropic', + preset: 'core', + }); const toolConfig = { - tools: tools.map((t) => ({ + tools: tools.map((t: any) => ({ toolSpec: { name: t.name, description: t.description, @@ -517,9 +514,11 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` while (true) { const res = await bedrock.send(new ConverseCommand({ + // Bedrock uses regional inference-profile IDs, not Anthropic model + // names โ€” copy the exact ID from your Bedrock console's model catalog. modelId: 'us.anthropic.claude-sonnet-4-6', messages, - system: [{ text: 'You edit .docx files using SuperDoc tools. Use tracked changes for all edits.' }], + system: [{ text: systemPrompt }], toolConfig, })); @@ -533,7 +532,7 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` const results = []; for (const block of toolUses) { const { name, input, toolUseId } = block.toolUse; - const result = await dispatchSuperDocTool(doc, name, input ?? {}); + const result = await dispatch(doc, name, input ?? {}); const json = typeof result === 'object' && result !== null ? result : { result }; results.push({ toolResult: { toolUseId, content: [{ json }] } }); } @@ -548,14 +547,14 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` ```python import boto3 - from superdoc import SuperDocClient, choose_tools, dispatch_superdoc_tool + from superdoc import SuperDocClient, create_agent_toolkit client = SuperDocClient() client.connect() doc = client.open({"doc": "./contract.docx"}) # Get tools in Anthropic format, convert to Bedrock toolSpec shape - sd_tools = choose_tools({"provider": "anthropic"}) + kit = create_agent_toolkit({"provider": "anthropic", "preset": "core"}) tool_config = { "tools": [ { @@ -565,7 +564,7 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` "inputSchema": {"json": t.get("input_schema", {})}, } } - for t in sd_tools["tools"] + for t in kit["tools"] ] } @@ -574,9 +573,11 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` while True: response = bedrock.converse( + # Bedrock uses regional inference-profile IDs, not Anthropic model + # names โ€” copy the exact ID from your Bedrock console's model catalog. modelId="us.anthropic.claude-sonnet-4-6", messages=messages, - system=[{"text": "You edit .docx files using SuperDoc tools. Use tracked changes for all edits."}], + system=[{"text": kit["system_prompt"]}], toolConfig=tool_config, ) @@ -590,7 +591,7 @@ Use `chooseTools({ provider: 'anthropic' })` and convert to Bedrock's `toolSpec` tool_results = [] for block in tool_uses: tu = block["toolUse"] - result = dispatch_superdoc_tool(doc, tu["name"], tu.get("input", {})) + result = kit["dispatch"](doc, tu["name"], tu.get("input", {})) json_result = result if isinstance(result, dict) else {"result": result} tool_results.append( {"toolResult": {"toolUseId": tu["toolUseId"], "content": [{"json": json_result}]}} diff --git a/apps/docs/ai/agents/legacy-preset.mdx b/apps/docs/ai/agents/legacy-preset.mdx new file mode 100644 index 0000000000..625816ef27 --- /dev/null +++ b/apps/docs/ai/agents/legacy-preset.mdx @@ -0,0 +1,226 @@ +--- +title: Legacy preset โ€” grouped intent tools +sidebarTitle: Legacy preset +description: "The original 10-tool surface: grouped intent tools with search-then-edit targeting. Maintained for existing integrations; new integrations should use the core preset." +keywords: "superdoc legacy preset, superdoc_edit, superdoc_search, superdoc_mutations, intent tools" +--- + + +**Not recommended for new integrations.** The legacy preset remains the **default** (omitting `preset` selects it) purely for backwards compatibility โ€” existing integrations keep working unchanged. For new work, use the [core preset](/ai/agents/core-preset): it scores measurably higher on our revision-fidelity evals, returns verifiable receipts instead of raw operation results, and treats tracked-changes redlining as a first-class workflow. + + +The legacy preset gives the model 10 grouped **intent tools** that map closely to [Document API](/document-api/overview) operations. The model works low-level: search for text to obtain handles/addresses, then edit by address. + +## Quick start + + + + ```typescript + import { createAgentToolkit } from '@superdoc-dev/sdk'; + + // 'legacy' is also what you get if you omit `preset`. + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'legacy', + }); + ``` + + + ```python + from superdoc import create_agent_toolkit + + kit = create_agent_toolkit({"provider": "openai", "preset": "legacy"}) + tools, system_prompt = kit["tools"], kit["system_prompt"] + ``` + + + +The legacy preset ignores `excludeActions` everywhere (it has no action surface to narrow) โ€” passing it is a harmless no-op. + +## Tool catalog + +Most tools use an `action` argument to select the underlying operation. Single-action tools like `superdoc_search` do not require `action`. + +| Tool | Actions | What it does | +| --- | --- | --- | +| `superdoc_get_content` | `text`, `markdown`, `html`, `blocks`, `extract`, `info` | Read document content in different formats | +| `superdoc_search` | _(single action)_ | Find text or nodes and return handles or addresses for later edits | +| `superdoc_edit` | `insert`, `replace`, `delete`, `undo`, `redo` | Perform text edits and history actions | +| `superdoc_format` | `inline`, `set_style`, `set_alignment`, `set_indentation`, `set_spacing`, `set_direction`, `set_flow_options` | Apply inline or paragraph formatting | +| `superdoc_create` | `paragraph`, `heading`, `table` | Create structural block elements | +| `superdoc_list` | `insert`, `create`, `attach`, `detach`, `delete`, `merge`, `split`, `indent`, `outdent`, `set_level`, `set_type`, `set_value`, `continue_previous` | Create and manipulate lists | +| `superdoc_comment` | `create`, `update`, `delete`, `get`, `list` | Manage comment threads | +| `superdoc_track_changes` | `list`, `decide` | Review and resolve tracked changes | +| `superdoc_mutations` | `preview`, `apply` | Execute multi-step atomic edits as a batch | +| `superdoc_table` | `delete`, `delete_column`, `delete_row`, `insert_column`, `insert_row`, `merge_cells`, `set_borders`, `set_cell`, `set_cell_text`, `set_column`, `set_layout`, `set_options`, `set_row`, `set_row_options`, `set_shading`, `set_style_options`, `unmerge_cells` | Modify table structure, content, and styling (find table/row/cell nodeIds via `superdoc_get_content` or `superdoc_search`) | + +## Behaviors you must know + +**`superdoc_search` `require` values** โ€” `first` returns the first match (error on zero), `all` returns every match, `any` returns matches without failing on zero, `exactlyOne` errors unless exactly one matched (`AMBIGUOUS_MATCH` on several, `MATCH_NOT_FOUND` on none). + +**Refs expire on mutation.** Every successful edit bumps the document revision and invalidates previously fetched refs โ€” using a stale ref raises `REVISION_MISMATCH`. This is the sharpest edge of the legacy surface: pre-fetching refs for two blocks and editing them sequentially **always fails on the second edit**. For multi-block edits use `superdoc_mutations` (which resolves all targets before any step executes and applies everything in one revision bump), or re-search between edits. + +**`get_content action:"text"` returns the whole document** with no pagination โ€” in an agent loop that result lives in conversation history forever and amplifies every later turn's token cost. Prefer scoped reads. + +## System prompt + +`getSystemPrompt()` / `getSystemPrompt('legacy')` returns the prompt this surface was designed with: the search-before-edit workflow, targeting rules, and batching guidance. Use it as-is or extend it โ€” and pair prompt and tools from the same preset. + +## Creating custom tools + +The built-in intent tools cover core editing operations. For anything else, create custom tools that call `doc.*` methods directly and merge them with the SDK tools. + +| Step | What you do | +| --- | --- | +| 1. Pick operations | Browse `doc.*` to find the methods you need | +| 2. Define the schema | Write a function tool definition for your provider | +| 3. Write a dispatcher | Map tool actions to `doc.*` calls | +| 4. Merge and use | Combine with SDK tools in your agentic loop | + +### Step 1: Pick your operations + +Every `doc.*` namespace maps to a group of [Document API](/document-api/overview) operations: + +```text +doc.hyperlinks โ†’ list, get, wrap, insert, patch, remove +doc.tables โ†’ get, insertRow, deleteRow, mergeCells, ... +doc.images โ†’ list, get, setSize, rotate, crop, ... +doc.footnotes โ†’ list, get, create, delete, update, ... +doc.bookmarks โ†’ list, get, create, delete, ... +``` + +### Step 2: Define the tool schema + +Group related operations under a single tool using an `action` enum. This matches the pattern the built-in tools use. + +```typescript +import type { ChatCompletionTool } from 'openai/resources/chat/completions'; + +const hyperlinkTool: ChatCompletionTool = { + type: 'function', + function: { + name: 'superdoc_hyperlink', + description: + 'Create, read, update, or remove hyperlinks in the document.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['list', 'get', 'wrap', 'insert', 'patch', 'remove'], + }, + target: { + description: 'Target address from superdoc_search results.', + }, + text: { type: 'string', description: 'Display text (insert only).' }, + href: { type: 'string', description: 'URL destination.' }, + tooltip: { type: 'string', description: 'Hover tooltip text.' }, + }, + required: ['action'], + additionalProperties: false, + }, + }, +}; +``` + + +- Keep descriptions short. The model reads every tool definition on each turn. +- Use `additionalProperties: false` to prevent hallucinated parameters. +- Reference `superdoc_search` in descriptions so the model knows how to get targets. + + +### Step 3: Write a dispatcher + +Map each action to the corresponding `doc.*` call: + +```typescript +import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; + +async function dispatchToolCall(doc, toolName, args) { + // Built-in tools: delegate to the SDK + if (toolName !== 'superdoc_hyperlink') { + return dispatchSuperDocTool(doc, toolName, args); + } + + // Custom tool: call doc.* directly + const { action, target, text, href, tooltip } = args; + + switch (action) { + case 'list': + return doc.hyperlinks.list({}); + case 'get': + return doc.hyperlinks.get({ target }); + case 'wrap': + return doc.hyperlinks.wrap({ + target, + link: { destination: { href }, ...(tooltip && { tooltip }) }, + }); + case 'insert': + return doc.hyperlinks.insert({ + text, + link: { destination: { href }, ...(tooltip && { tooltip }) }, + ...(target && { target }), + }); + case 'patch': + return doc.hyperlinks.patch({ + target, + patch: { ...(href && { href }), ...(tooltip && { tooltip }) }, + }); + case 'remove': + return doc.hyperlinks.remove({ target }); + default: + throw new Error(`Unknown action: "${action}"`); + } +} +``` + +### Step 4: Merge and use + +Combine your custom tool with the SDK tools and use your dispatcher in the agentic loop: + +```typescript +const { tools: sdkTools } = await chooseTools({ provider: 'openai' }); +const allTools = [...sdkTools, hyperlinkTool]; + +// In your agentic loop, use your dispatcher instead of dispatchSuperDocTool: +// OpenAI chat completions store the tool name on function.name. +const toolName = toolCall.function.name; +const result = await dispatchToolCall(doc, toolName, args); +``` + +### Extending the system prompt + +For custom tools, append usage instructions to the SDK system prompt so the model knows how to use them: + +```typescript +const systemPrompt = await getSystemPrompt(); + +const customInstructions = ` +## superdoc_hyperlink + +Use this tool to manage hyperlinks. First use superdoc_search to find +text you want to link, then pass the handle as target to the wrap action. +`; + +const fullPrompt = systemPrompt + '\n' + customInstructions; +``` + +## Migrating to core + +The mapping is conceptual, not mechanical โ€” core actions absorb multi-call legacy patterns into single verbs: + +| Legacy pattern | Core equivalent | +| --- | --- | +| `superdoc_search` โ†’ `superdoc_edit` (replace by ref) | `replace_text` (finds and edits in one call) | +| `superdoc_search` โ†’ `superdoc_mutations` (multi-block batch) | one action call โ€” each action resolves its own targets atomically | +| `superdoc_create` + `superdoc_format` chains | `insert_paragraphs` / `insert_heading` / `create_table` with `placement` | +| `superdoc_track_changes {action:"decide"}` | `accept_tracked_changes` / `reject_tracked_changes` (with `author`/`changeType` filters) | +| `superdoc_comment` | `add_comments` / `reply_to_comment` / `resolve_comments` | + +Switching is a one-line change per call site (`preset: 'core'` on the toolkit) plus adopting the receipt contract โ€” see the [core preset reference](/ai/agents/core-preset). + +## Related + +- [Core preset reference](/ai/agents/core-preset) โ€” the recommended surface +- [How it works](/ai/agents/architecture) โ€” SDK โ†” CLI โ†” LLM mechanics +- [Overview](/ai/agents/llm-tools) โ€” providers, token budget, errors, troubleshooting diff --git a/apps/docs/ai/agents/llm-tools.mdx b/apps/docs/ai/agents/llm-tools.mdx index 1e2cd17a8b..444b0968cd 100644 --- a/apps/docs/ai/agents/llm-tools.mdx +++ b/apps/docs/ai/agents/llm-tools.mdx @@ -1,11 +1,49 @@ --- title: AI Agents sidebarTitle: Overview -description: "Document tools that plug into any LLM provider: what they do and how they work" +description: "Document tools that plug into any LLM provider: what they do, how the pieces connect, and how to run a reliable agent loop" keywords: "llm tools, ai document editing, openai tools, anthropic tools, tool use, function calling, superdoc sdk, document automation" --- -The SuperDoc SDK ships tool definitions that give LLMs structured access to document operations. They cover reading, searching, editing, formatting, lists, comments, tracked changes, and batched mutations. Pick a provider format, pass the tools to your model, dispatch the calls, and the SDK handles schema formatting, argument validation, and execution. +The SuperDoc SDK ships tool definitions that give LLMs structured access to document operations: reading, searching, editing, formatting, lists, tables, comments, and tracked changes. Pick a provider format, pass the tools to your model, dispatch the calls, and the SDK handles schema formatting, argument validation, and execution. + +## How the pieces fit + +```mermaid +flowchart LR + UI["Your chat UI +
SuperDoc editor"] -->|"user request"| APP["Your agent loop"] + APP <-->|"messages + tools
tool calls + results"| LLM["LLM provider"] + APP <-->|"toolkit: tools ยท prompt ยท dispatch"| SDK["@superdoc-dev/sdk"] + SDK <-->|"sessions + operations"| CLI["SuperDoc CLI host
the document engine"] +``` + +Your loop is the broker: the model only ever sees tools, a system prompt, and tool results; documents live in sessions inside the CLI host the SDK spawns; the browser editor renders the same file for the user. Three rules prevent most first-hour confusion: + +1. **The SDK is server-side** โ€” `dispatch` needs a session-bound handle from `createSuperDocClient().open(...)`; it does not run in the browser. +2. **The editor is browser-side** โ€” never import `superdoc` / `@superdoc-dev/react` in backend code or API routes. +3. **Pair everything from one preset** โ€” tools, system prompt, and dispatch must come from the same preset (the toolkit guarantees this). + + +The full mechanics โ€” what crosses the SDK โ†” CLI boundary, sessions and revisions, a complete tool-call round trip with sequence diagrams, and where Python and MCP fit โ€” have their own page: **[How it works](/ai/agents/architecture)**. + + +## Two presets + +The SDK ships two tool surfaces. Pass the same `preset` to `chooseTools`, `getSystemPrompt`, and `dispatchSuperDocTool`. + +| | `legacy` (default) | `core` | +| --- | --- | --- | +| Surface | 10 grouped intent tools (`superdoc_edit`, `superdoc_search`, โ€ฆ) | 2 tools: `superdoc_inspect` + `superdoc_perform_action` (40 named actions) | +| Style | Low-level: search for handles, then edit by address | High-level: named product verbs with deterministic targeting | +| Results | Operation results | Receipts with pre/post evidence and verification | +| Tracked changes | Via `changeMode` on individual ops | First-class: every mutating action is redline-aware, plus accept/reject/undo/redo actions | +| Best for | Fine-grained control, existing integrations | Agent loops, review/redlining workflows, fastest correct results | + + +**Use the core preset for new integrations.** Legacy remains the default only for backwards compatibility โ€” existing integrations keep working unchanged. Core scores measurably higher on our revision-fidelity evals and returns verifiable receipts. Each preset has its own reference page: [core](/ai/agents/core-preset) ยท [legacy](/ai/agents/legacy-preset). + + +Both presets are also served over **MCP**: the SuperDoc MCP server registers the legacy intent tools by default, or the core action surface with `MCP_PRESET=core` (two tools plus session lifecycle, with the core MCP instructions). ## Quick start @@ -18,19 +56,23 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. ``` ```typescript - import { createSuperDocClient, chooseTools, dispatchSuperDocTool } from '@superdoc-dev/sdk'; + import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; import OpenAI from 'openai'; const client = createSuperDocClient(); await client.connect(); const doc = await client.open({ doc: './contract.docx' }); - const { tools } = await chooseTools({ provider: 'openai' }); + // One call โ€” tools, system prompt, and dispatch, guaranteed coherent. + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', + }); const openai = new OpenAI(); - const messages = [ - { role: 'system', content: 'You edit documents using the provided tools.' }, - { role: 'user', content: 'Find the termination clause and rewrite it to allow 30-day notice.' }, + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: 'Find the termination clause and rewrite it to allow 30-day notice. Use tracked changes.' }, ]; while (true) { @@ -46,11 +88,7 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. if (!message.tool_calls?.length) break; for (const call of message.tool_calls) { - const result = await dispatchSuperDocTool( - doc, - call.function.name, - JSON.parse(call.function.arguments), - ); + const result = await dispatch(doc, call.function.name, JSON.parse(call.function.arguments)); messages.push({ role: 'tool', tool_call_id: call.id, @@ -69,25 +107,29 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. pip install superdoc-sdk openai ``` + The PyPI package is `superdoc-sdk`, but the import is `from superdoc import โ€ฆ` โ€” `import superdoc_sdk` will raise `ModuleNotFoundError`. + ```python import json - import openai - from superdoc import SuperDocClient, choose_tools, dispatch_superdoc_tool + from openai import OpenAI + from superdoc import SuperDocClient, create_agent_toolkit + client_llm = OpenAI() # uses OPENAI_API_KEY env var client = SuperDocClient() client.connect() doc = client.open({"doc": "./contract.docx"}) - result = choose_tools({"provider": "openai"}) - tools = result["tools"] + # One call โ€” tools, system prompt, and dispatch, guaranteed coherent. + kit = create_agent_toolkit({"provider": "openai", "preset": "core"}) + tools, system_prompt = kit["tools"], kit["system_prompt"] messages = [ - {"role": "system", "content": "You edit documents using the provided tools."}, - {"role": "user", "content": "Find the termination clause and rewrite it to allow 30-day notice."}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "Find the termination clause and rewrite it to allow 30-day notice. Use tracked changes."}, ] while True: - response = openai.chat.completions.create( + response = client_llm.chat.completions.create( model="gpt-5.4", messages=messages, tools=tools ) message = response.choices[0].message @@ -97,13 +139,12 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. break for call in message.tool_calls: - result = dispatch_superdoc_tool( - doc, call.function.name, json.loads(call.function.arguments) - ) + result = kit["dispatch"](doc, call.function.name, json.loads(call.function.arguments)) messages.append({ "role": "tool", "tool_call_id": call.id, - "content": json.dumps(result), + # Receipts may contain non-JSON-serializable values; default=str is safe. + "content": json.dumps(result, default=str), }) doc.save({"inPlace": True}) @@ -115,7 +156,40 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. ## Tool selection -`chooseTools()` returns provider-formatted tool definitions ready to pass to your LLM. +The one-call setup โ€” tools, system prompt, and a pre-bound dispatcher that always agree on preset and exclusions: + + + + ```typescript + import { createAgentToolkit } from '@superdoc-dev/sdk'; + + const { tools, systemPrompt, dispatch, meta } = await createAgentToolkit({ + provider: 'openai', + preset: 'core', + excludeActions: ['delete_table'], // applied to tools, prompt, AND dispatch + }); + + // in the loop: + const receipt = await dispatch(doc, call.function.name, JSON.parse(call.function.arguments)); + ``` + + + ```python + from superdoc import create_agent_toolkit + + kit = create_agent_toolkit({"provider": "openai", "preset": "core", + "excludeActions": ["delete_table"]}) + tools, system_prompt = kit["tools"], kit["system_prompt"] + + receipt = kit["dispatch"](doc, call.function.name, json.loads(call.function.arguments)) + # async loops: kit["dispatch_async"](...) + ``` + + + +The toolkit makes preset/exclusion mismatches impossible by construction โ€” an excluded action is simultaneously out of the tool enum, out of the system prompt, and refused at dispatch. The standalone functions below remain available when you need the pieces individually; if you use them with `excludeActions`, pass the **same list to all three**. + +`chooseTools()` returns provider-formatted tool definitions plus metadata about the selection. @@ -124,40 +198,28 @@ Install the SDK, create a client, open a document, and wire up an agentic loop. const { tools, meta } = await chooseTools({ provider: 'openai', // 'openai' | 'anthropic' | 'vercel' | 'generic' + preset: 'core', // omit for the default 'legacy' surface }); + + // meta = { preset: 'core', provider: 'openai', toolCount: 2, cacheStrategy: 'disabled' } + // cacheStrategy: 'disabled' | 'explicit' | 'automatic' | 'unsupported' โ€” + // this call returns 'disabled'; anthropic with cache: true returns 'explicit'. ``` ```python from superdoc import choose_tools - result = choose_tools({"provider": "openai"}) + result = choose_tools({"provider": "openai", "preset": "core"}) tools = result["tools"] + meta = result["meta"] # preset, provider, toolCount, cacheStrategy ``` -The current SDK returns the full grouped intent tool set for the selected provider. Group filtering and meta-discovery are not part of the shipped public API here. - -## Tool catalog +## Legacy tool catalog -The generated catalog currently contains 9 grouped intent tools. Most tools use an `action` argument to select the underlying operation. Single-action tools like `superdoc_search` do not require `action`. - -| Tool | Actions | What it does | -| --- | --- | --- | -| `superdoc_get_content` | `text`, `markdown`, `html`, `info` | Read document content in different formats | -| `superdoc_search` | _(single action)_ | Find text or nodes and return handles or addresses for later edits | -| `superdoc_edit` | `insert`, `replace`, `delete`, `undo`, `redo` | Perform text edits and history actions | -| `superdoc_format` | `inline`, `set_style`, `set_alignment`, `set_indentation`, `set_spacing`, `set_direction`, `set_flow_options` | Apply inline or paragraph formatting | -| `superdoc_create` | `paragraph`, `heading`, `table` | Create structural block elements | -| `superdoc_list` | `insert`, `create`, `detach`, `indent`, `outdent`, `set_level`, `set_type` | Create and manipulate lists | -| `superdoc_comment` | `create`, `update`, `delete`, `get`, `list` | Manage comment threads | -| `superdoc_track_changes` | `list`, `decide` | Review and resolve tracked changes | -| `superdoc_mutations` | `preview`, `apply` | Execute multi-step atomic edits as a batch | - - -Built-in tools cover the core operations. For tables, images, hyperlinks, and anything else, [create custom tools](#creating-custom-tools) that call any `doc.*` operation today. - +The legacy preset's 10 grouped intent tools, their behaviors (`superdoc_search` `require` semantics, ref expiry, `superdoc_mutations` batching), and the migration mapping to core actions now live on the [legacy preset page](/ai/agents/legacy-preset). ## Dispatching tool calls @@ -168,219 +230,261 @@ Built-in tools cover the core operations. For tables, images, hyperlinks, and an ```typescript import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - const result = await dispatchSuperDocTool(doc, toolName, args); + const result = await dispatchSuperDocTool(doc, toolName, args, { preset: 'core' }); ```
```python from superdoc import dispatch_superdoc_tool - result = dispatch_superdoc_tool(doc, tool_name, args) + result = dispatch_superdoc_tool(doc, tool_name, args, preset="core") ``` ```python from superdoc import dispatch_superdoc_tool_async - result = await dispatch_superdoc_tool_async(doc, tool_name, args) + result = await dispatch_superdoc_tool_async(doc, tool_name, args, preset="core") ```
-The dispatcher validates required parameters, checks that arguments are compatible, and throws descriptive errors the LLM can act on. +The dispatcher validates required parameters, rejects unknown arguments, and throws descriptive errors the LLM can act on. `doc` must be the session-bound handle from `client.open(...)` โ€” a plain object or a browser editor instance will not work. ## System prompt -`getSystemPrompt()` returns a default prompt that teaches the model the tool workflow: targeting, search-before-edit, and common patterns. It's optional. You can use it as-is, extend it with your own instructions, or write a completely custom prompt. +`getSystemPrompt(preset?)` returns the prompt each tool surface was designed โ€” and evaluated โ€” with. It teaches the model the document vocabulary the tools use (blocks, ordinals, markers, visual sections), when to inspect before editing, how to read results, and the tracked-changes rules. -```typescript -import { getSystemPrompt } from '@superdoc-dev/sdk'; + + + ```typescript + import { getSystemPrompt } from '@superdoc-dev/sdk'; -const systemPrompt = await getSystemPrompt(); -``` + const legacyPrompt = await getSystemPrompt(); // legacy surface + const corePrompt = await getSystemPrompt('core'); // action surface + ``` + + + ```python + from superdoc import get_system_prompt + + core_prompt = get_system_prompt("core") + ``` + + + +Guidance: + +- **Use it as-is** as your system message, or as the first section of one. +- **Extend, don't replace**: append your product's instructions (tone, guardrails, domain language) after it. The prompt's tool-usage sections encode behavior the schemas alone can't teach; dropping it measurably degrades edit quality. +- Pair prompt and tools from the **same preset** โ€” the prompt documents exactly the surface the model was given. ## Provider formats -Each provider gets tool definitions in its native format. +Each provider gets tool definitions in its native format: ```typescript - const { tools } = await chooseTools({ provider: 'openai' }); + const { tools } = await chooseTools({ provider: 'openai', preset: 'core' }); // [{ type: 'function', function: { name, description, parameters } }] ``` ```typescript - const { tools } = await chooseTools({ provider: 'anthropic' }); + const { tools } = await chooseTools({ provider: 'anthropic', preset: 'core', cache: true }); // [{ name, description, input_schema }] + // With cache: true, the last tool carries cache_control for prompt caching + // (see Token budget). Without it, no cache markers are added. ``` ```typescript - const { tools } = await chooseTools({ provider: 'vercel' }); - // [{ type: 'function', function: { name, description, parameters } }] + const { tools } = await chooseTools({ provider: 'vercel', preset: 'core' }); + // [{ name, description, inputSchema }] โ€” AI SDK dialect ``` ```typescript - const { tools } = await chooseTools({ provider: 'generic' }); - // [{ name, description, parameters, returns, metadata }] + const { tools } = await chooseTools({ provider: 'generic', preset: 'core' }); + // [{ name, description, parameters }] ``` -## Creating custom tools + +**Agent loops are not provider-interchangeable.** The tool *definitions* adapt automatically, but the message protocol does not: OpenAI uses `message.tool_calls` + `role: "tool"` replies; Anthropic uses `tool_use` content blocks + `role: "user"` messages containing `tool_result` blocks. Budget for a per-provider loop โ€” the Anthropic variant is below. + -The built-in tools cover core editing operations. For advanced features like tables, images, hyperlinks, footnotes, and citations, create custom tools that call `doc.*` methods directly. +### Anthropic loop -| Step | What you do | -| --- | --- | -| 1. Pick operations | Browse `doc.*` to find the methods you need | -| 2. Define the schema | Write a function tool definition for your provider | -| 3. Write a dispatcher | Map tool actions to `doc.*` calls | -| 4. Merge and use | Combine with SDK tools in your agentic loop | + + + ```typescript + import Anthropic from '@anthropic-ai/sdk'; + import { chooseTools, getSystemPromptForProvider, dispatchSuperDocTool } from '@superdoc-dev/sdk'; -### Step 1: Pick your operations + const anthropic = new Anthropic(); + // cache: true on both halves of the static prefix โ€” the tool array and the + // system prompt โ€” so Anthropic caches them across turns (see Token budget). + const { tools } = await chooseTools({ provider: 'anthropic', preset: 'core', cache: true }); + const sys = await getSystemPromptForProvider({ provider: 'anthropic', preset: 'core', cache: true }); -Every `doc.*` namespace maps to a group of [Document API](/document-api/overview) operations: + const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Rewrite the termination clause for 30-day notice, tracked.' }, + ]; -```text -doc.hyperlinks โ†’ list, get, wrap, insert, patch, remove -doc.tables โ†’ get, insertRow, deleteRow, mergeCells, ... -doc.images โ†’ list, get, setSize, rotate, crop, ... -doc.footnotes โ†’ list, get, create, delete, update, ... -doc.bookmarks โ†’ list, get, create, delete, ... -``` + while (true) { + const response = await anthropic.messages.create({ + model: 'claude-sonnet-5', + max_tokens: 4096, + system: sys.content, // text blocks carrying cache_control markers + tools, + messages, + }); + messages.push({ role: 'assistant', content: response.content }); -### Step 2: Define the tool schema + const toolUses = response.content.filter((block) => block.type === 'tool_use'); + if (toolUses.length === 0) break; -Group related operations under a single tool using an `action` enum. This matches the pattern the built-in tools use. + const results = []; + for (const use of toolUses) { + const result = await dispatchSuperDocTool(doc, use.name, use.input, { preset: 'core' }); + results.push({ type: 'tool_result', tool_use_id: use.id, content: JSON.stringify(result) }); + } + messages.push({ role: 'user', content: results }); + } + ``` + + + ```python + import json + import anthropic + from superdoc import choose_tools, get_system_prompt, dispatch_superdoc_tool -```typescript -import type { ChatCompletionTool } from 'openai/resources/chat/completions'; - -const hyperlinkTool: ChatCompletionTool = { - type: 'function', - function: { - name: 'superdoc_hyperlink', - description: - 'Create, read, update, or remove hyperlinks in the document.', - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['list', 'get', 'wrap', 'insert', 'patch', 'remove'], - }, - target: { - description: 'Target address from superdoc_search results.', - }, - text: { type: 'string', description: 'Display text (insert only).' }, - href: { type: 'string', description: 'URL destination.' }, - tooltip: { type: 'string', description: 'Hover tooltip text.' }, - }, - required: ['action'], - additionalProperties: false, - }, - }, -}; -``` + client_llm = anthropic.Anthropic() + tools = choose_tools({"provider": "anthropic", "preset": "core", "cache": True})["tools"] + # The Node SDK wraps this in getSystemPromptForProvider; in Python, build + # the cacheable system block directly: + system = [{ + "type": "text", + "text": get_system_prompt("core"), + "cache_control": {"type": "ephemeral"}, + }] - -- Keep descriptions short. The model reads every tool definition on each turn. -- Use `additionalProperties: false` to prevent hallucinated parameters. -- Reference `superdoc_search` in descriptions so the model knows how to get targets. - + messages = [{"role": "user", "content": "Rewrite the termination clause for 30-day notice, tracked."}] -### Step 3: Write a dispatcher + while True: + response = client_llm.messages.create( + model="claude-sonnet-5", max_tokens=4096, + system=system, tools=tools, messages=messages, + ) + messages.append({"role": "assistant", "content": response.content}) -Map each action to the corresponding `doc.*` call: + tool_uses = [b for b in response.content if b.type == "tool_use"] + if not tool_uses: + break -```typescript -import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; - -async function dispatchToolCall(doc, toolName, args) { - // Built-in tools: delegate to the SDK - if (toolName !== 'superdoc_hyperlink') { - return dispatchSuperDocTool(doc, toolName, args); - } - - // Custom tool: call doc.* directly - const { action, target, text, href, tooltip } = args; - - switch (action) { - case 'list': - return doc.hyperlinks.list({}); - case 'get': - return doc.hyperlinks.get({ target }); - case 'wrap': - return doc.hyperlinks.wrap({ - target, - link: { destination: { href }, ...(tooltip && { tooltip }) }, - }); - case 'insert': - return doc.hyperlinks.insert({ - text, - link: { destination: { href }, ...(tooltip && { tooltip }) }, - ...(target && { target }), - }); - case 'patch': - return doc.hyperlinks.patch({ - target, - patch: { ...(href && { href }), ...(tooltip && { tooltip }) }, - }); - case 'remove': - return doc.hyperlinks.remove({ target }); - default: - throw new Error(`Unknown action: "${action}"`); - } -} -``` + results = [] + for use in tool_uses: + result = dispatch_superdoc_tool(doc, use.name, use.input, preset="core") + results.append({ + "type": "tool_result", + "tool_use_id": use.id, + "content": json.dumps(result, default=str), + }) + messages.append({"role": "user", "content": results}) + ``` + + -### Step 4: Merge and use +## Token budget -Combine your custom tool with the SDK tools and use your dispatcher in the agentic loop: +Tool schemas and the system prompt are re-sent on **every** turn, and every tool result lives in conversation history forever. Untended, a typical loop crosses low-tier per-minute token ceilings within a few turns. What the SDK gives you and what to do yourself: -```typescript -const { tools: sdkTools } = await chooseTools({ provider: 'openai' }); -const allTools = [...sdkTools, hyperlinkTool]; +- **Prompt caching (Anthropic)** โ€” pass `cache: true` to `chooseTools({ provider: 'anthropic', cache: true, ... })`: the SDK marks the tool array with `cache_control: {type: 'ephemeral'}` so the static prefix is cached across turns (~90% cost reduction on the cached portion). For the other half of the prefix, `getSystemPromptForProvider({ provider: 'anthropic', cache: true })` returns the system prompt as cacheable system blocks โ€” pass its `content` as the `system` parameter (the [Anthropic loop](#anthropic-loop) shows both together). +- **Narrow the surface** โ€” `excludeActions` (core preset) removes actions from the schema *and* the prompt in one move. +- **Windowed reads** โ€” on large documents, inspect in block windows (`blockOffset`/`blockLimit`) instead of pulling the whole document into history; with legacy `superdoc_get_content action:"text"`, be aware the full text lands in history on every use. +- **Receipts are pre-capped** โ€” core-preset receipts cap long per-item lists at 8 entries with count fields, specifically to keep history lean. +- **Plan for 429s** โ€” tier-1 accounts should implement exponential backoff and history truncation from day one. -// In your agentic loop, use your dispatcher instead of dispatchSuperDocTool: -// OpenAI chat completions store the tool name on function.name. -const toolName = toolCall.function.name; -const result = await dispatchToolCall(doc, toolName, args); -``` +## Error codes + +Runtime errors carry a stable `code` your loop (and your model) can branch on: -### Extending the system prompt +| Code | Meaning | Recoverable? | +| --- | --- | --- | +| `REVISION_MISMATCH` | A ref/handle from before a mutation was used after it (legacy) or the session revision guard failed | Yes โ€” re-search / re-inspect and retry | +| `AMBIGUOUS_MATCH` | `exactlyOne` matched several occurrences | Yes โ€” narrow the pattern or use `all` | +| `MATCH_NOT_FOUND` | Target text/element not found; **nothing was changed** | Yes โ€” re-inspect, fix the target | +| `INVALID_ARGUMENT` / `INVALID_INPUT` | Bad or unknown arguments (includes actions excluded by configuration) | Fix the call | +| `TOOL_DISPATCH_NOT_FOUND` | Tool name unknown to the selected preset | Fix preset/tool pairing | +| `TOOLS_ASSET_NOT_FOUND` / `TOOLS_ASSET_UNREADABLE` | Bundled prompt asset missing vs. unreadable (IO/permissions โ€” details carry the cause) | Environment issue | +| `HOST_HANDSHAKE_FAILED` | CLI host binary could not start | No โ€” fix the environment (see below) | -For custom tools, append usage instructions to the SDK system prompt so the model knows how to use them: +Core-preset action failures additionally return structured `recovery` hints (`reinspect` / `retry` / `revert` with a paste-ready call) inside the receipt. -```typescript -const systemPrompt = await getSystemPrompt(); +## Troubleshooting: `Host process disconnected` -const customInstructions = ` -## superdoc_hyperlink +This one error has several distinct causes โ€” check in order: -Use this tool to manage hyperlinks. First use superdoc_search to find -text you want to link, then pass the handle as target to the wrap action. -`; +1. **macOS Gatekeeper killed the unsigned binary** (SIGKILL at launch). Check `xattr -d com.apple.quarantine ` / your MDM policy. +2. **Unsupported Node version** โ€” the SDK supports current LTS versions, but doesn't declare `engines`, so npm won't warn you at install time. Check `node --version` first. +3. **The host crashed mid-call** โ€” enable transport debug logs (`DEBUG=superdoc.transport`) to see the host's stderr and exit code. +4. **Next.js bundling** โ€” mark the SDK as external (`serverExternalPackages: ['@superdoc-dev/sdk']`) so the native binary isn't bundled away. -const fullPrompt = systemPrompt + '\n' + customInstructions; +## Streaming status to your UI + +The agent loop is the natural place to emit progress events โ€” each tool call is a meaningful step. Server-sent events sketch: + +```typescript +// Express/Node SSE endpoint around the agent loop +for (const call of message.tool_calls) { + const args = JSON.parse(call.function.arguments); + send({ type: 'tool_start', tool: call.function.name, action: args.action ?? null }); + + const receipt = (await dispatchSuperDocTool(doc, call.function.name, args, { + preset: 'core', + })) as { status?: string; verificationPassed?: boolean }; + + send({ + type: 'tool_done', + tool: call.function.name, + action: args.action ?? null, + status: receipt.status ?? 'ok', // core receipts: ok | partial | failed + verified: receipt.verificationPassed ?? null, + }); + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) }); +} +send({ type: 'assistant_message', text: finalText }); ``` +Core-preset receipts make the events meaningful for users: `action` names read like product verbs ("replace_text", "add_comments"), and `status`/`verified` let you render success/warning states without parsing prose. For the final message, instruct the model (in your appended system-prompt section) to end with a short user-facing summary of what changed โ€” receipts give it the evidence to be specific. + +## Creating custom tools + +Custom capabilities are documented per preset: + +- **Core preset** โ€” [Creating custom actions](/ai/agents/core-preset#creating-custom-actions): the `defineAction` authoring kit (coming soon). +- **Legacy preset** โ€” [Creating custom tools](/ai/agents/legacy-preset#creating-custom-tools): define provider tools that call `doc.*` operations and merge them with the SDK's. + ## SDK functions | Function | Description | | --- | --- | -| `chooseTools(input)` | Load grouped tool definitions for a provider | -| `dispatchSuperDocTool(doc, name, args)` | Execute a tool call against a bound document handle | -| `listTools(provider)` | List all tool definitions for a provider | -| `getToolCatalog()` | Load the full tool catalog with metadata | -| `getSystemPrompt()` | Read the bundled system prompt for intent tools | +| `createAgentToolkit(input)` | One call: coherent `{tools, systemPrompt, dispatch, meta}` for a preset (recommended) | +| `chooseTools(input)` | Load tool definitions for a provider (`preset`, `excludeActions`, `cache` options) | +| `dispatchSuperDocTool(doc, name, args, options?)` | Execute a tool call against a bound document handle | +| `listTools(provider, preset?)` | List all tool definitions for a provider | +| `getToolCatalog(preset?)` | Load the full tool catalog with metadata | +| `getSystemPrompt(preset?, options?)` | Read the bundled system prompt for a tool surface | +| `getSystemPromptForProvider(input)` | System prompt shaped for a provider โ€” for `anthropic` with `cache: true`, returns system blocks carrying `cache_control` (Node only; in Python build the block from `get_system_prompt`) | ## Related +- [How it works](/ai/agents/architecture): SDK โ†” CLI โ†” LLM mechanics with sequence diagrams +- [Core preset reference](/ai/agents/core-preset): the two-tool action surface, all 40 actions, receipts, redlining +- [Legacy preset reference](/ai/agents/legacy-preset): the previous 10-tool surface (not recommended for new work) - [How to use](/ai/agents/integrations): step-by-step integration guide with copy-pasteable code - [Best practices](/ai/agents/best-practices): prompting, workflow tips, and tested prompt examples - [Debugging](/ai/agents/debugging): troubleshoot tool call failures diff --git a/apps/docs/ai/mcp/overview.mdx b/apps/docs/ai/mcp/overview.mdx index 83b82628be..4d1d9c729a 100644 --- a/apps/docs/ai/mcp/overview.mdx +++ b/apps/docs/ai/mcp/overview.mdx @@ -9,22 +9,50 @@ The SuperDoc MCP server lets AI agents open, read, edit, and save `.docx` files. ## How it works -The MCP server runs as a local subprocess, communicating over stdio. It manages document sessions in memory: each `superdoc_open` creates an Editor instance, and all subsequent operations run against that in-memory state until you `superdoc_save`. - +Your MCP client spawns the server as a local subprocess and talks to it over stdio. The server embeds the SuperDoc document engine โ€” the same engine the browser editor uses โ€” and manages documents as in-memory **sessions**: `superdoc_open` loads a file and returns a `session_id`, every tool call targets that session, and nothing touches disk until `superdoc_save`. + +```mermaid +flowchart LR + subgraph client["Your MCP client"] + AGENT["AI agent
Claude Code ยท Claude Desktop
Cursor ยท Windsurf
"] + end + subgraph server["@superdoc-dev/mcp โ€” local subprocess"] + direction TB + TOOLS["Registered tools
lifecycle + preset surface"] + SM["Session manager
session_id โ†’ live document"] + ENGINE["Document engine (in-memory)
same engine as the SuperDoc editor"] + TOOLS --> SM --> ENGINE + end + DISK[(".docx files
on disk")] + + AGENT <-->|"MCP protocol (stdio)
tools/list ยท tools/call"| TOOLS + DISK -->|"superdoc_open"| ENGINE + ENGINE -->|"superdoc_save"| DISK ``` -AI Agent (Claude, Cursor, Windsurf) - โ”‚ MCP protocol (stdio) - โ–ผ -@superdoc-dev/mcp - โ”‚ Document API - โ–ผ -SuperDoc Editor (in-memory) - โ”‚ export - โ–ผ -.docx file on disk + +A typical conversation, end to end: + +```mermaid +sequenceDiagram + autonumber + participant A as AI agent + participant S as MCP server + participant D as .docx on disk + + A->>S: superdoc_open {path: "contract.docx"} + S->>D: read file into an in-memory session + S-->>A: session_id + A->>S: superdoc_inspect / read tools {session_id} + S-->>A: document structure + A->>S: edit tools {session_id, ...} + Note over S: edits apply to the live session โ€”
tracked changes, comments, formatting + S-->>A: results / receipts + A->>S: superdoc_save {session_id} + S->>D: write the updated .docx + A->>S: superdoc_close {session_id} ``` -The MCP server runs locally: SuperDoc never uploads your files. The AI agent you connect still sends content to its own provider. +Everything runs locally โ€” SuperDoc never uploads your files. The AI agent you connect still sends document content to its own model provider as tool results. ## Setup @@ -82,12 +110,27 @@ Install once. Your MCP client spawns the server automatically on each conversati ## Tools -The MCP server exposes 12 tools total: +The server registers one of two tool surfaces, selected by the `MCP_PRESET` environment variable. All tools except `superdoc_open` take a `session_id` from `superdoc_open`. -- 3 lifecycle tools: `superdoc_open`, `superdoc_save`, `superdoc_close` -- 9 grouped intent tools generated from the SDK catalog - -All tools except `superdoc_open` take a `session_id` from `superdoc_open`. +| | Default (`legacy`) | `MCP_PRESET=core` โ€” recommended | +| --- | --- | --- | +| Tools | 13: lifecycle + 10 grouped intent tools | 5: lifecycle + `superdoc_inspect` + `superdoc_perform_action` (40 named actions) | +| Style | Low-level: search for handles, edit by address | High-level verbs with deterministic targeting and verifiable receipts | +| Reference | tables below | [core preset reference](/ai/agents/core-preset) | + +To use the core surface, add the env to your client config: + +```json +{ + "mcpServers": { + "superdoc": { + "command": "npx", + "args": ["@superdoc-dev/mcp"], + "env": { "MCP_PRESET": "core" } + } + } +} +``` ### Lifecycle @@ -99,17 +142,22 @@ All tools except `superdoc_open` take a `session_id` from `superdoc_open`. ### Intent tools + +These are the **legacy preset's** tools (the default surface). With `MCP_PRESET=core` the server registers `superdoc_inspect` and `superdoc_perform_action` instead โ€” see the [core preset reference](/ai/agents/core-preset) for its 40 actions, selectors, and receipts. + + | Tool | Actions | Description | | --- | --- | --- | -| `superdoc_get_content` | `text`, `markdown`, `html`, `info` | Read document content in different formats | -| `superdoc_search` | `match` | Find text or nodes and return handles or addresses for later edits | +| `superdoc_get_content` | `text`, `markdown`, `html`, `blocks`, `extract`, `info` | Read document content in different formats | +| `superdoc_search` | _(single action)_ | Find text or nodes and return handles or addresses for later edits | | `superdoc_edit` | `insert`, `replace`, `delete`, `undo`, `redo` | Perform text edits and history actions | -| `superdoc_format` | `inline`, `set_style`, `set_alignment`, `set_indentation`, `set_spacing` | Apply inline or paragraph formatting | -| `superdoc_create` | `paragraph`, `heading` | Create structural block elements | -| `superdoc_list` | `insert`, `create`, `detach`, `indent`, `outdent`, `set_level`, `set_type` | Create and manipulate lists | +| `superdoc_format` | `inline`, `set_style`, `set_alignment`, `set_indentation`, `set_spacing`, `set_direction`, `set_flow_options` | Apply inline or paragraph formatting | +| `superdoc_create` | `paragraph`, `heading`, `table` | Create structural block elements | +| `superdoc_list` | `insert`, `create`, `attach`, `detach`, `delete`, `merge`, `split`, `indent`, `outdent`, `set_level`, `set_type`, `set_value`, `continue_previous` | Create and manipulate lists | | `superdoc_comment` | `create`, `update`, `delete`, `get`, `list` | Manage comment threads | | `superdoc_track_changes` | `list`, `decide` | Review and resolve tracked changes | | `superdoc_mutations` | `preview`, `apply` | Execute multi-step atomic edits as a batch | +| `superdoc_table` | `delete`, `delete_column`, `delete_row`, `insert_column`, `insert_row`, `merge_cells`, `set_borders`, `set_cell`, `set_cell_text`, `set_column`, `set_layout`, `set_options`, `set_row`, `set_row_options`, `set_shading`, `set_style_options`, `unmerge_cells` | Modify table structure, content, and styling | Multi-action tools use an `action` argument to select the underlying operation. `superdoc_search` is a single-action tool and does not require `action`. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index c81cdc87ee..2b61a86d70 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -196,6 +196,11 @@ "group": "Agents", "pages": [ "ai/agents/llm-tools", + "ai/agents/architecture", + { + "group": "Presets", + "pages": ["ai/agents/core-preset", "ai/agents/legacy-preset"] + }, "ai/agents/integrations", "ai/agents/best-practices", "ai/agents/debugging", @@ -295,7 +300,7 @@ ] }, "banner": { - "content": "**Bring your own editor UI.** Use SuperDoc as the document engine underneath. [Read the announcement โ†’](https://www.superdoc.dev/changelog/2026-05-01-bring-your-own-editor)", + "content": "**Bring your own editor UI.** Use SuperDoc as the document engine underneath. [Read the announcement \u2192](https://www.superdoc.dev/changelog/2026-05-01-bring-your-own-editor)", "dismissible": true }, "redirects": [ diff --git a/apps/docs/package.json b/apps/docs/package.json index eb79518505..59b09fb2f7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -12,7 +12,8 @@ "check:icons": "bun scripts/validate-icons.ts", "check:em-dashes": "bun scripts/validate-em-dashes.ts", "check:types": "tsx __tests__/doctest-types.ts", - "test:examples": "bun test __tests__/doctest.test.ts" + "test:examples": "bun test __tests__/doctest.test.ts", + "check:ai-snippets": "node scripts/validate-ai-snippets.mjs" }, "devDependencies": { "documentation": "^14.0.3", diff --git a/apps/docs/scripts/validate-ai-snippets.mjs b/apps/docs/scripts/validate-ai-snippets.mjs new file mode 100644 index 0000000000..4ee87a0142 --- /dev/null +++ b/apps/docs/scripts/validate-ai-snippets.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +/** + * AI-docs snippet gate: every code block on the core-preset / agents pages + * must work against the REAL SDK surface. + * + * What it checks, per fence language: + * - jsonc/json โ€” parsed (comments stripped). Blocks with an "action" key + * are validated against the live core-preset catalog: the + * action must exist in the superdoc_perform_action enum and + * every top-level arg key must be a declared schema + * property. Inspect-shaped blocks validate against the + * superdoc_inspect schema. Selector/placement fragments + * validate their `kind`/`at` vocabulary. + * - typescript โ€” every `import {...} from '@superdoc-dev/sdk'` symbol must + * exist on the built SDK; snippets are also compiled with + * tsc against the dist types (fragments get ambient decls). + * - python โ€” compiled with ast.parse; every `from superdoc import ...` + * symbol must exist in the python package's __all__. + * - bash โ€” CLI invocations are replayed against the real CLI. Any + * non-zero exit fails the block, except session/document + * preconditions a bare checkout can't satisfy. + * + * Requires built SDK dist (pnpm --prefix packages/sdk/langs/node build) + * and built CLI dist (pnpm --prefix apps/cli build). + * Run: node scripts/validate-ai-snippets.mjs + */ +import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DOCS = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO = resolve(DOCS, '../..'); +const SDK_DIST = join(REPO, 'packages/sdk/langs/node/dist/index.js'); +const PAGES = [ + 'ai/agents/core-preset.mdx', + 'ai/agents/llm-tools.mdx', + 'ai/agents/legacy-preset.mdx', + 'ai/agents/architecture.mdx', + 'ai/agents/integrations.mdx', + 'ai/agents/best-practices.mdx', + 'ai/agents/debugging.mdx', +]; + +if (!existsSync(SDK_DIST)) { + console.error('validate-ai-snippets: SDK dist missing โ€” run: pnpm --prefix packages/sdk/langs/node build'); + process.exit(2); +} +if (!existsSync(join(REPO, 'apps/cli/dist/index.js'))) { + console.error('validate-ai-snippets: CLI dist missing โ€” run: pnpm --prefix apps/cli build'); + process.exit(2); +} + +const sdk = await import(SDK_DIST); +const catalog = await sdk.getPreset('core').getCatalog(); +const perform = catalog.tools.find((t) => t.toolName === 'superdoc_perform_action'); +const inspect = catalog.tools.find((t) => t.toolName === 'superdoc_inspect'); +if (!perform || !inspect) { + console.error('validate-ai-snippets: core catalog is missing superdoc_perform_action/superdoc_inspect โ€” rebuild the SDK dist.'); + process.exit(2); +} +const ACTION_ENUM = new Set(perform.inputSchema.properties.action.enum); +const legacyCatalog = await sdk.getPreset('legacy').getCatalog(); +const LEGACY_ACTIONS = new Set( + legacyCatalog.tools.flatMap((t) => t.inputSchema?.properties?.action?.enum ?? []), +); +const PERFORM_KEYS = new Set(Object.keys(perform.inputSchema.properties)); +const INSPECT_KEYS = new Set(Object.keys(inspect.inputSchema.properties)); +const SELECTOR_KINDS = new Set(['nodeId', 'ordinal', 'textSearch', 'tableCell', 'placement', 'relative']); +const PLACEMENT_AT = new Set(['document_end', 'document_start', 'after', 'before']); + +let failures = 0; +const fail = (page, idx, lang, msg) => { + failures += 1; + console.error(` โœ— ${page} block[${idx}] (${lang}): ${msg}`); +}; + +function stripJsonc(text) { + const lines = text.split('\n').map((line) => { + let inString = false; + for (let i = 0; i < line.length; i += 1) { + if (line[i] === '"' && line[i - 1] !== '\\') inString = !inString; + if (!inString && line[i] === '/' && line[i + 1] === '/') return line.slice(0, i).trimEnd(); + } + return line; + }); + return lines.join('\n').replace(/,\s*([}\]])/g, '$1'); +} + +function checkJsonBlock(page, idx, code) { + // A jsonc block may contain several standalone objects (one per line-group). + const objects = []; + const chunks = code.split(/\n(?=\{)/).map((c) => stripJsonc(c).trim()).filter(Boolean); + for (const chunk of chunks) { + try { + objects.push(JSON.parse(chunk)); + } catch (e) { + fail(page, idx, 'jsonc', `does not parse: ${e.message} :: ${chunk.slice(0, 60)}`); + return; + } + } + for (const obj of objects) { + if (typeof obj.action === 'string') { + if (ACTION_ENUM.has(obj.action)) { + for (const key of Object.keys(obj)) { + if (!PERFORM_KEYS.has(key)) fail(page, idx, 'jsonc', `arg "${key}" not in superdoc_perform_action schema`); + } + } else if (!LEGACY_ACTIONS.has(obj.action)) { + fail(page, idx, 'jsonc', `action "${obj.action}" exists in neither the core enum nor any legacy tool`); + } + } else if (typeof obj.kind === 'string') { + if (!SELECTOR_KINDS.has(obj.kind)) fail(page, idx, 'jsonc', `unknown selector kind "${obj.kind}"`); + } else if (typeof obj.at === 'string') { + if (!PLACEMENT_AT.has(obj.at)) fail(page, idx, 'jsonc', `unknown placement at "${obj.at}"`); + } else if (Object.keys(obj).some((k) => INSPECT_KEYS.has(k))) { + for (const key of Object.keys(obj)) { + if (!INSPECT_KEYS.has(key)) fail(page, idx, 'jsonc', `arg "${key}" not in superdoc_inspect schema`); + } + } + } +} + +function checkTsImports(page, idx, code) { + const m = code.match(/import\s*\{([^}]+)\}\s*from\s*'@superdoc-dev\/sdk'/g) ?? []; + for (const imp of m) { + const names = imp + .replace(/import\s*\{|\}\s*from.*/g, '') + .split(',') + .map((n) => n.trim()) + .filter(Boolean); + for (const name of names) { + if (name.startsWith('type ')) continue; // type-only: validated by the tsc pass + const exported = name.split(/\s+as\s+/)[0].trim(); + if (!(exported in sdk)) fail(page, idx, 'ts', `'${exported}' is not exported by @superdoc-dev/sdk`); + } + } +} + +function checkPython(page, idx, code, pyInfo) { + const { execFileSync } = pyInfo; + const tmp = join(pyInfo.dir, `snippet_${idx}.py`); + writeFileSync(tmp, code); + try { + execFileSync('python3', ['-c', `import ast,sys; ast.parse(open(${JSON.stringify(tmp)}).read())`]); + } catch (e) { + fail(page, idx, 'python', `syntax error: ${String(e.stderr ?? e.message).slice(0, 120)}`); + return; + } + const m = code.match(/from superdoc import ([^\n]+)/g) ?? []; + for (const imp of m) { + const names = imp.replace('from superdoc import ', '').split(',').map((n) => n.trim().split(' ')[0]); + for (const name of names) { + if (!pyInfo.exports.has(name)) fail(page, idx, 'python', `'${name}' not exported by the superdoc package`); + } + } +} + +const CLI_DIST = join(REPO, 'apps/cli/dist/index.js'); +// Snippet preconditions a bare checkout can't satisfy (no live session, no +// sample document). Everything else that exits non-zero is a real failure โ€” +// pattern-matching only known-bad output proved too easy to slip past. +const TOLERATED_PRECONDITIONS = /SESSION_NOT_FOUND|session .{0,40}not found|DOC(UMENT)?_NOT_FOUND|no such file|ENOENT/i; + +function checkBash(page, idx, code) { + for (const rawLine of code.split(/\\\n/).join(' ').split('\n')) { + const line = rawLine.trim(); + if (!line.startsWith('superdoc ')) continue; + const args = line.replace(/^superdoc\s+/, '').match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; + try { + // Run the real invocation โ€” appending --help would short-circuit before + // flag validation and mask unknown-option errors (it did once). + execFileSync('node', [CLI_DIST, ...args.map((a) => a.replace(/^['"]|['"]$/g, ''))], { + stdio: 'pipe', + timeout: 30_000, + }); + } catch (e) { + const out = `${e.stdout ?? ''}${e.stderr ?? ''}`; + const flagError = /unknown option|unknown flag|INVALID_ARGUMENT|MISSING_REQUIRED/i.test(out); + // Fail on any non-zero exit unless it's ONLY a missing-session/document + // precondition. Flag/validation errors fail even alongside one. + if (flagError || !TOLERATED_PRECONDITIONS.test(out)) { + fail(page, idx, 'bash', `CLI rejected (exit ${e.status ?? '?'}): ${line.slice(0, 80)} :: ${out.slice(0, 160) || e.message.slice(0, 160)}`); + } + } + } +} + +// python package exports +const pyDir = mkdtempSync(join(tmpdir(), 'ai-snippets-')); +const pyExportsRaw = execFileSync('python3', ['-c', ` +import sys; sys.path.insert(0, ${JSON.stringify(join(REPO, 'packages/sdk/langs/python'))}) +import superdoc; print(','.join(superdoc.__all__)) +`]).toString(); +const pyExports = new Set(pyExportsRaw.trim().split(',')); +const pyInfo = { dir: pyDir, exports: pyExports, execFileSync }; + +// TS compile pass setup โ€” ambient names are injected per snippet, and only +// when the snippet USES the name without declaring or importing it (docs +// fragments legitimately assume surrounding context like `doc` or `messages`). +const tsDir = mkdtempSync(join(tmpdir(), 'ai-snippets-ts-')); +const AMBIENT_DECLS = { + doc: "declare const doc: import('@superdoc-dev/sdk').BoundDocApi;", + call: 'declare const call: { id: string; function: { name: string; arguments: string } };', + toolCall: 'declare const toolCall: any;', + message: 'declare const message: any;', + response: 'declare const response: any;', + choice: 'declare const choice: any;', + messages: 'declare const messages: any[];', + args: 'declare const args: Record;', + name: 'declare const name: string;', + toolName: 'declare const toolName: string;', + openai: 'declare const openai: any;', + anthropic: 'declare const anthropic: any;', + model: 'declare const model: string;', + finalText: 'declare const finalText: string;', + receipt: 'declare const receipt: any;', + kit: 'declare const kit: any;', + tools: 'declare const tools: any[];', + sdkTools: 'declare const sdkTools: any[];', + systemPrompt: 'declare const systemPrompt: string;', + dispatch: 'declare const dispatch: (...a: any[]) => Promise;', + client: 'declare const client: any;', + send: 'declare function send(event: unknown): void;', + hyperlinkTool: 'declare const hyperlinkTool: any;', + dispatchToolCall: 'declare function dispatchToolCall(doc: any, toolName: string, args: any): Promise;', + streamFromServer: 'declare function streamFromServer(prompt: string, opts: any): AsyncIterable;', + signal: 'declare const signal: AbortSignal;', + buffer: 'declare let buffer: string;', + pendingFlush: 'declare let pendingFlush: any;', + editor: 'declare const editor: any;', + activeEditor: 'declare const activeEditor: any;', + superdoc: 'declare const superdoc: any;', + prompt: 'declare const prompt: string;', + res: 'declare const res: any;', + chooseTools: "declare const chooseTools: typeof import('@superdoc-dev/sdk').chooseTools;", + getSystemPrompt: "declare const getSystemPrompt: typeof import('@superdoc-dev/sdk').getSystemPrompt;", + dispatchSuperDocTool: "declare const dispatchSuperDocTool: typeof import('@superdoc-dev/sdk').dispatchSuperDocTool;", + createAgentToolkit: "declare const createAgentToolkit: typeof import('@superdoc-dev/sdk').createAgentToolkit;", + listTools: "declare const listTools: typeof import('@superdoc-dev/sdk').listTools;", + getToolCatalog: "declare const getToolCatalog: typeof import('@superdoc-dev/sdk').getToolCatalog;", +}; +function ambientFor(code) { + const out = []; + for (const [name, decl] of Object.entries(AMBIENT_DECLS)) { + const used = new RegExp(`\\b${name}\\b`).test(code); + if (!used) continue; + const declared = new RegExp(`\\b(const|let|var|function|class)\\s+(\\{[^}]*\\b${name}\\b[^}]*\\}|${name}\\b)`).test(code) + || new RegExp(`\\{[^}]*\\b${name}\\b[^}]*\\}\\s*=`).test(code) + || new RegExp(`import[^;]*\\b${name}\\b[^;]*from`).test(code); + if (!declared) out.push(decl); + } + return out.join('\n'); +} +const tsFiles = []; + +function dedent(code) { + const lines = code.split('\n'); + const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length); + const cut = indents.length ? Math.min(...indents) : 0; + return lines.map((l) => l.slice(cut)).join('\n'); +} + +let blockTotal = 0; +for (const page of PAGES) { + const text = readFileSync(join(DOCS, page), 'utf8'); + const blocks = [...text.matchAll(/```(\w+)[^\n]*\n(.*?)```/gs)].map((m) => ({ lang: m[1], code: dedent(m[2]) })); + blocks.forEach(({ lang, code }, idx) => { + blockTotal += 1; + if (lang === 'jsonc' || lang === 'json') checkJsonBlock(page, idx, code); + else if (lang === 'typescript' || lang === 'ts') { + checkTsImports(page, idx, code); + const file = join(tsDir, `${page.replace(/[^a-z0-9]+/gi, '_')}_${idx}.ts`); + writeFileSync(file, `export {};\n${ambientFor(code)}\n${code}`); + tsFiles.push(file); + } else if (lang === 'python') checkPython(page, idx, code, pyInfo); + else if (lang === 'bash') checkBash(page, idx, code); + }); +} + +// Single tsc pass over all TS snippets against the dist types. +writeFileSync(join(tsDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + strict: false, + noEmit: true, + skipLibCheck: true, + module: 'esnext', + target: 'es2022', + moduleResolution: 'bundler', + paths: { + '@superdoc-dev/sdk': [join(REPO, 'packages/sdk/langs/node/dist/index.d.ts')], + 'openai': [join(tsDir, 'openai-stub.d.ts')], + 'openai/resources/chat/completions': [join(tsDir, 'openai-cc-stub.d.ts')], + '@anthropic-ai/sdk': [join(tsDir, 'anthropic-stub.d.ts')], + 'ai': [join(tsDir, 'ai-stub.d.ts')], + '@ai-sdk/openai': [join(tsDir, 'ai-sdk-openai-stub.d.ts')], + '@aws-sdk/client-bedrock-runtime': [join(tsDir, 'bedrock-stub.d.ts')], + }, + }, + include: ['*.ts'], +}, null, 2)); +writeFileSync(join(tsDir, 'anthropic-stub.d.ts'), ` +declare class Anthropic { constructor(...a: any[]); messages: any; } +declare namespace Anthropic { type MessageParam = any; type ToolResultBlockParam = any; } +export default Anthropic;`); +writeFileSync(join(tsDir, 'openai-stub.d.ts'), ` +declare class OpenAI { constructor(...a: any[]); chat: any; } +declare namespace OpenAI { namespace Chat { namespace Completions { type ChatCompletionMessageParam = any; } type ChatCompletionMessageParam = any; } } +export default OpenAI;`); +writeFileSync(join(tsDir, 'openai-cc-stub.d.ts'), 'export type ChatCompletionTool = any;'); +writeFileSync(join(tsDir, 'ai-stub.d.ts'), ` +export declare function generateText(o: any): Promise; +export declare function jsonSchema(s: any): any; +export declare function stepCountIs(n: number): any; +export declare function tool(d: any): any;`); +writeFileSync(join(tsDir, 'ai-sdk-openai-stub.d.ts'), 'export declare const openai: ((model: string) => any) & { chat(model: string): any };'); +writeFileSync(join(tsDir, 'bedrock-stub.d.ts'), ` +export declare class BedrockRuntimeClient { constructor(...a: any[]); send(c: any): Promise; } +export declare class ConverseCommand { constructor(...a: any[]); }`); +try { + execFileSync('npx', ['tsc', '-p', tsDir], { cwd: join(REPO, 'apps/docs'), stdio: 'pipe', timeout: 180_000 }); + console.log(` โœ“ ${tsFiles.length} TypeScript snippets compile against the SDK dist types`); +} catch (e) { + const out = `${e.stdout ?? ''}`; + for (const line of out.split('\n')) { + if (line.includes('error TS')) { fail('(tsc)', '-', 'ts', line.trim().replace(/^.*ai-snippets-ts-[^/]+\//, '').slice(0, 220)); } + } +} + +rmSync(pyDir, { recursive: true, force: true }); +rmSync(tsDir, { recursive: true, force: true }); + +console.log(`\nai-snippets: ${blockTotal} blocks checked across ${PAGES.length} pages, ${failures} failure(s)`); +process.exit(failures > 0 ? 1 : 0); diff --git a/examples/README.md b/examples/README.md index 2eb56ad15e..22d333a05f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -121,6 +121,7 @@ Document editing through models and agents. | [streaming](./ai/streaming) | Stream model output into a visible editor | | [redlining](./ai/redlining) | LLM-driven tracked-change review (browser) | | [footnote-tool-agent](./ai/footnote-tool-agent) | Real LLM tool-use loop: model picks `addFootnoteCitation`, browser executes against `editor.doc` | +| [core-actions-agent](./ai/core-actions-agent) | Headless agent on the `core` preset: 2 tools, 40 actions, receipts, tracked-changes redlining (Node + Python) | ## Advanced diff --git a/examples/ai/core-actions-agent/README.md b/examples/ai/core-actions-agent/README.md new file mode 100644 index 0000000000..787daf56d9 --- /dev/null +++ b/examples/ai/core-actions-agent/README.md @@ -0,0 +1,44 @@ +# Core actions agent + +A minimal, headless agent that edits a `.docx` from a natural-language instruction using the SuperDoc SDK's **`core` LLM-tools preset** โ€” two tools (`superdoc_inspect`, `superdoc_perform_action`), forty deterministic actions, receipts with verification. + +Docs: [Core preset reference](https://docs.superdoc.dev/ai/agents/core-preset) ยท [AI agents overview](https://docs.superdoc.dev/ai/agents/llm-tools) + +## Setup + +```bash +pnpm install # from the repo root (workspace) +export OPENAI_API_KEY=sk-... +``` + +Or standalone outside the monorepo: replace the `workspace:*` dependency with the published `@superdoc-dev/sdk`. + +In a **dev checkout** (no published platform binary installed), point the SDK at the locally built CLI: + +```bash +export SUPERDOC_CLI_BIN=/apps/cli/dist/index.js # pnpm --prefix apps/cli run build +``` + +## Run (Node) + +```bash +node agent.mjs ./contract.docx "Rewrite the termination clause to allow 30-day notice." + +# Redlining: every edit becomes a tracked change a reviewer can accept/reject +node agent.mjs ./contract.docx "Tighten the confidentiality clause." --tracked --out reviewed.docx +``` + +## Run (Python) + +```bash +pip install superdoc-sdk openai # import name is `superdoc` +python agent.py ./contract.docx "Add a short summary paragraph at the top." --tracked +``` + +## What to look at + +- **One preset everywhere** โ€” both scripts load tools, the system prompt, and the dispatcher through a single `createAgentToolkit({ preset: 'core', ... })` (`create_agent_toolkit` in Python) call, so they can never disagree on preset or exclusions. Mixing presets between hand-assembled calls is the most common integration mistake โ€” the toolkit makes it impossible. +- **Receipts as status lines** โ€” each tool call prints `โ†’ โ€ฆ ok|partial|failed`, exactly the events you would stream to a chat UI over SSE. +- **`--tracked`** โ€” appends a tracked-changes instruction; the model sets `changeMode: "tracked"` on every mutating action, producing redline suggestions instead of direct edits. + +Both scripts cap the loop at 16 turns and save to `out.docx` by default. diff --git a/examples/ai/core-actions-agent/agent.mjs b/examples/ai/core-actions-agent/agent.mjs new file mode 100644 index 0000000000..334c9980b6 --- /dev/null +++ b/examples/ai/core-actions-agent/agent.mjs @@ -0,0 +1,113 @@ +/** + * Core-preset agent: edit a .docx from a natural-language instruction. + * + * node agent.mjs "" [--tracked] [--out ] + * + * Demonstrates the `core` LLM-tools preset end to end via createAgentToolkit: + * - tools โ†’ 2 tools (superdoc_inspect, superdoc_perform_action) + * - systemPrompt โ†’ the prompt the action surface is evaluated with + * - dispatch โ†’ pre-bound to the preset; returns receipts with verification + * + * Each tool call is printed as a status line โ€” the same shape you would + * stream to a chat UI (see the "Streaming status to your UI" docs section). + */ +import 'dotenv/config'; +import OpenAI from 'openai'; +import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk'; + +const MODEL = process.env.OPENAI_MODEL ?? 'gpt-5.4'; +const MAX_TURNS = 16; + +// --------------------------------------------------------------------------- +// CLI arguments +// --------------------------------------------------------------------------- +const args = process.argv.slice(2); +const tracked = args.includes('--tracked'); +const outFlag = args.indexOf('--out'); +if (outFlag >= 0 && (args[outFlag + 1] == null || args[outFlag + 1].startsWith('--'))) { + console.error('--out requires a path argument'); + process.exit(1); +} +const outPath = outFlag >= 0 ? args[outFlag + 1] : 'out.docx'; +const positional = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--out'); +const [inputPath, instruction] = positional; + +if (!inputPath || !instruction) { + console.error('Usage: node agent.mjs "" [--tracked] [--out ]'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// SuperDoc session + core-preset tool surface +// --------------------------------------------------------------------------- +const client = createSuperDocClient(); +await client.connect(); +const doc = await client.open({ doc: inputPath }); + +// Everything after open() runs inside try/finally so an API error or a +// malformed response never leaks the session or (in dev checkouts) the +// locally spawned CLI process. +try { + + // One call โ€” tools, system prompt, and a pre-bound dispatcher that are + // guaranteed to agree on preset (and excludeActions, if you narrow). + const { tools, systemPrompt, dispatch } = await createAgentToolkit({ provider: 'openai', preset: 'core' }); + + const userInstruction = tracked + ? `${instruction}\n\nMake every edit as a tracked change (changeMode: "tracked") so a reviewer can accept or reject it.` + : instruction; + + const messages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userInstruction }, + ]; + + // --------------------------------------------------------------------------- + // Agent loop + // --------------------------------------------------------------------------- + const openai = new OpenAI(); + + for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const response = await openai.chat.completions.create({ model: MODEL, messages, tools }); + const message = response.choices[0].message; + messages.push(message); + + if (!message.tool_calls?.length) { + console.log(`\n${message.content ?? '(no final message)'}`); + break; + } + + for (const call of message.tool_calls) { + let receipt; + try { + // Malformed tool-call arguments become an error receipt the model can + // read and correct, instead of crashing the run. + const callArgs = JSON.parse(call.function.arguments); + process.stdout.write(` โ†’ ${callArgs.action ?? call.function.name} โ€ฆ `); + receipt = await dispatch(doc, call.function.name, callArgs); + const status = receipt?.status ?? 'ok'; + const verified = receipt?.verificationPassed; + console.log(status + (verified === false ? ' (verification failed)' : '')); + } catch (error) { + receipt = { status: 'failed', error: { code: error.code, message: error.message } }; + console.log(`error: ${error.code ?? error.message}`); + } + + messages.push({ + role: 'tool', + tool_call_id: call.id, + content: JSON.stringify(receipt), + }); + } + } + + // --------------------------------------------------------------------------- + // Save + // --------------------------------------------------------------------------- + await doc.save({ out: outPath, force: true }); + console.log(`\nSaved: ${outPath}`); + +} finally { + await doc.close({ discard: true }).catch(() => {}); + await client.dispose(); +} diff --git a/examples/ai/core-actions-agent/agent.py b/examples/ai/core-actions-agent/agent.py new file mode 100644 index 0000000000..bdca4199f7 --- /dev/null +++ b/examples/ai/core-actions-agent/agent.py @@ -0,0 +1,108 @@ +"""Core-preset agent (Python twin of agent.mjs). + + python agent.py "" [--tracked] [--out ] + +Demonstrates the `core` LLM-tools preset from the Python SDK via +create_agent_toolkit โ€” one call returning tools, the evaluated system prompt, +and a dispatcher pre-bound to the preset (receipts with verification). + +Requires: pip install superdoc-sdk openai (import name is `superdoc`). +""" + +from __future__ import annotations + +import json +import os +import sys + +from openai import OpenAI +from superdoc import SuperDocClient, create_agent_toolkit + +MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.4") +MAX_TURNS = 16 + + +def parse_args(argv: list[str]) -> tuple[str, str, bool, str]: + tracked = "--tracked" in argv + out_path = "out.docx" + if "--out" in argv: + out_path = argv[argv.index("--out") + 1] + positional = [ + a for i, a in enumerate(argv) + if not a.startswith("--") and (i == 0 or argv[i - 1] != "--out") + ] + if len(positional) < 2: + print('Usage: python agent.py "" [--tracked] [--out ]') + sys.exit(1) + return positional[0], positional[1], tracked, out_path + + +def main() -> None: + input_path, instruction, tracked, out_path = parse_args(sys.argv[1:]) + + # One call โ€” tools, system prompt, and a pre-bound dispatcher that are + # guaranteed to agree on preset (and excludeActions, if you narrow). + kit = create_agent_toolkit({"provider": "openai", "preset": "core"}) + tools, system_prompt, dispatch = kit["tools"], kit["system_prompt"], kit["dispatch"] + + if tracked: + instruction += ( + '\n\nMake every edit as a tracked change (changeMode: "tracked") ' + "so a reviewer can accept or reject it." + ) + + llm = OpenAI() + + with SuperDocClient() as client: + doc = client.open({"doc": input_path}) + # Everything after open() runs inside try/finally so an API error or a + # malformed tool call never leaks the session (mirrors agent.mjs). + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": instruction}, + ] + + for _turn in range(MAX_TURNS): + response = llm.chat.completions.create(model=MODEL, messages=messages, tools=tools) + message = response.choices[0].message + messages.append(message) + + if not message.tool_calls: + print(f"\n{message.content or '(no final message)'}") + break + + for call in message.tool_calls: + try: + # Malformed tool-call arguments become an error receipt + # the model can read and correct, instead of a crash. + call_args = json.loads(call.function.arguments) + print(f" -> {call_args.get('action', call.function.name)} ... ", end="", flush=True) + receipt = dispatch(doc, call.function.name, call_args) + status = receipt.get("status", "ok") if isinstance(receipt, dict) else "ok" + verified = receipt.get("verificationPassed") if isinstance(receipt, dict) else None + print(status + (" (verification failed)" if verified is False else "")) + except Exception as error: + # SuperDocError carries a structured .code โ€” keep the + # receipt shape identical to agent.mjs. + receipt = { + "status": "failed", + "error": {"code": getattr(error, "code", None), "message": str(error)}, + } + print(f"error: {getattr(error, 'code', None) or error}") + + messages.append({ + "role": "tool", + "tool_call_id": call.id, + # Receipts can contain non-JSON-serializable values. + "content": json.dumps(receipt, default=str), + }) + + doc.save({"out": out_path, "force": True}) + print(f"\nSaved: {out_path}") + finally: + doc.close({"discard": True}) + + +if __name__ == "__main__": + main() diff --git a/examples/ai/core-actions-agent/package.json b/examples/ai/core-actions-agent/package.json new file mode 100644 index 0000000000..2e06ec089c --- /dev/null +++ b/examples/ai/core-actions-agent/package.json @@ -0,0 +1,13 @@ +{ + "name": "@superdoc-examples/ai-core-actions-agent", + "private": true, + "type": "module", + "scripts": { + "start": "node agent.mjs" + }, + "dependencies": { + "@superdoc-dev/sdk": "workspace:*", + "dotenv": "^17.4.2", + "openai": "^6.33.0" + } +} diff --git a/examples/manifest.json b/examples/manifest.json index e99c8e2fd6..9a770b0124 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -449,6 +449,21 @@ "docs": "https://docs.superdoc.dev/ai/overview", "ci": true }, + { + "id": "ai-core-actions-agent", + "section": "ai", + "subsection": "agents", + "kind": "minimal-example", + "status": "active", + "sourceKind": "local", + "title": "Core actions agent", + "category": "AI", + "surface": "SDK (Node + Python)", + "sourceRepo": "superdoc-dev/superdoc", + "sourcePath": "examples/ai/core-actions-agent", + "docs": "https://docs.superdoc.dev/ai/agents/core-preset", + "ci": false + }, { "id": "document-engine-ai-redlining", "section": "document-engine", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 200a0a08e7..5cb8da1114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1667,6 +1667,18 @@ importers: specifier: ^4.21.0 version: 4.21.0 + examples/ai/core-actions-agent: + dependencies: + '@superdoc-dev/sdk': + specifier: workspace:* + version: link:../../../packages/sdk/langs/node + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + openai: + specifier: ^6.33.0 + version: 6.35.0(ws@8.20.0)(zod@4.3.6) + examples/ai/footnote-tool-agent: dependencies: dotenv: @@ -46757,7 +46769,7 @@ snapshots: jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.105.4(esbuild@0.27.7)(webpack-cli@5.1.4) + webpack: 5.105.4(esbuild@0.27.7)(webpack-cli@6.0.1) optionalDependencies: esbuild: 0.27.7