diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml
index 12c4a0deee..e6fbfeb564 100644
--- a/.github/workflows/promote-stable.yml
+++ b/.github/workflows/promote-stable.yml
@@ -84,8 +84,8 @@ jobs:
git show ":3:${f}" | normalize_json_without_keys version > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
;;
pyproject.toml|*/pyproject.toml)
- git show ":2:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
- git show ":3:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
+ git show ":2:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
+ git show ":3:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
;;
version.json|*/version.json)
git show ":2:${f}" | normalize_json_without_keys version sdkVersion > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml
index 5abf46d8e7..68c8544825 100644
--- a/.github/workflows/release-vscode-ext.yml
+++ b/.github/workflows/release-vscode-ext.yml
@@ -2,7 +2,7 @@
# Stable releases are orchestrated centrally by release-stable.yml so that
# every stable release shares one concurrency slot and one git push lane.
# Note: VS Code Marketplace doesn't support semver prerelease versions, so
-# main pushes only build a .vsix and attach it to the GitHub release.
+# main pushes create prerelease tags without publishing a .vsix.
name: ๐ฆ Release vscode-ext
on:
diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml
index 980eb49041..d463d59416 100644
--- a/.github/workflows/sync-patches.yml
+++ b/.github/workflows/sync-patches.yml
@@ -1,12 +1,11 @@
name: ๐ Sync stable โ main
-# Stable pushes first run semantic-release, which can append version/tag
-# commits back to stable. Sync only after that release lane finishes so main
-# never gets a PR from an intermediate stable head.
+# Stable releases can append version and tag commits. Wait for that lane to
+# drain, then pin its settled head before merging it into main.
on:
workflow_run:
workflows:
- - "๐ฆ Release stable tooling (CLI/SDK/MCP)"
+ - '๐ฆ Release stable tooling (CLI/SDK/MCP)'
types:
- completed
workflow_dispatch:
@@ -14,7 +13,6 @@ on:
permissions:
actions: read
contents: write
- pull-requests: write
concurrency:
group: sync-stable-to-main
@@ -27,15 +25,13 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(
github.event.workflow_run.head_branch == 'stable' &&
- (
- github.event.workflow_run.conclusion == 'success' ||
- github.event.workflow_run.conclusion == 'failure'
- )
+ github.event.workflow_run.conclusion == 'success'
)
steps:
+ # Manual dispatch is the recovery path after an investigated failure,
+ # but it still waits for active stable releases before pinning stable.
- name: Wait for stable release lane to drain
- if: github.event_name == 'workflow_run'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
@@ -52,7 +48,7 @@ jobs:
--jq '[.[] | select((.name == "๐ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐ฆ Release esign" or .name == "๐ฆ Release template-builder") and .status != "completed")] | length')
if [ "$active_runs" -eq 0 ]; then
- echo "Stable release lane is idle."
+ echo "Stable release lane is drained."
break
fi
@@ -85,7 +81,8 @@ jobs:
- name: Sync stable into main
env:
- GH_TOKEN: ${{ steps.generate_token.outputs.token }}
+ EVENT_NAME: ${{ github.event_name }}
+ TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
@@ -93,18 +90,59 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main stable --tags --prune
+ stable_sha=$(git rev-parse origin/stable)
- SYNC_BRANCH="sync/stable-to-main-$(date +%Y%m%d-%H%M%S)"
- git checkout -b "$SYNC_BRANCH" origin/main
+ if [ "$EVENT_NAME" = "workflow_run" ]; then
+ if ! git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"; then
+ echo "Pinned stable SHA does not descend from the successful tooling release."
+ exit 1
+ fi
+
+ release_subject_pattern='^chore(\([^)]+\))?: [^[:space:]]+ \[skip ci\]$'
+ unexpected_commit=false
+ while read -r sha subject; do
+ [ -z "$sha" ] && continue
+
+ author_email=$(git show -s --format='%ae' "$sha")
+ committer_email=$(git show -s --format='%ce' "$sha")
+ if [ "$author_email" != "semantic-release-bot@martynus.net" ] ||
+ [ "$committer_email" != "semantic-release-bot@martynus.net" ] ||
+ [[ ! "$subject" =~ $release_subject_pattern ]]; then
+ echo "Pinned stable includes a non-release commit: $sha $subject"
+ unexpected_commit=true
+ continue
+ fi
+
+ changed_files=$(git diff-tree --no-commit-id --name-only -r "$sha")
+ if [ -z "$changed_files" ]; then
+ echo "Release writeback has no version files: $sha"
+ unexpected_commit=true
+ continue
+ fi
+
+ while read -r f; do
+ case "$f" in
+ package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) ;;
+ *)
+ echo "Release writeback changes an unexpected file: $sha $f"
+ unexpected_commit=true
+ ;;
+ esac
+ done <<< "$changed_files"
+ done < <(git log --format='%H %s' "$TRIGGER_STABLE_SHA..$stable_sha")
+
+ if [ "$unexpected_commit" = true ]; then
+ exit 1
+ fi
+ fi
- if git merge-base --is-ancestor origin/stable origin/main; then
+ git checkout --detach origin/main
+
+ if git merge-base --is-ancestor "$stable_sha" origin/main; then
echo "No changes to sync - stable is already in main's ancestry."
exit 0
fi
- PR_TITLE="๐ Sync stable โ main"
- MERGE_STATUS="clean"
-
normalize_json_without_keys() {
python3 -c 'import json, sys; data = json.load(sys.stdin); [data.pop(key, None) for key in sys.argv[1:]]; sys.stdout.write(json.dumps(data, sort_keys=True, separators=(",", ":")))' "$@"
}
@@ -121,8 +159,8 @@ jobs:
git show ":3:${f}" | normalize_json_without_keys version > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
;;
pyproject.toml|*/pyproject.toml)
- git show ":2:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
- git show ":3:${f}" | sed -E '/^[[:space:]]*version[[:space:]]*=/d' > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
+ git show ":2:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
+ git show ":3:${f}" | python3 scripts/normalize-pyproject-release-artifact.py > "${tmpdir}/theirs" || { rm -rf "${tmpdir}"; return 1; }
;;
version.json|*/version.json)
git show ":2:${f}" | normalize_json_without_keys version sdkVersion > "${tmpdir}/ours" || { rm -rf "${tmpdir}"; return 1; }
@@ -140,17 +178,13 @@ jobs:
return "${status}"
}
- # Use a real merge, not a squash, so stable release tags become
- # reachable from main. semantic-release uses reachable tags as the
- # version floor for @next prereleases.
- if git merge --no-ff --no-edit origin/stable; then
- MERGE_STATUS="clean"
- else
+ # A real merge makes stable release tags reachable from main, which
+ # semantic-release needs as the version floor for @next.
+ if ! git merge --no-ff --no-edit "$stable_sha"; then
echo "Merge conflict โ attempting auto-resolution of release artifacts..."
- # Auto-resolve release artifact conflicts only when the conflict is
- # version-only: keep stable's already-published version, but never
- # drop dependency, script, export, or package metadata changes.
+ # Only published-version fields may take stable's side. Other
+ # package metadata conflicts remain manual.
while read -r f; do
case "$f" in
package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json)
@@ -166,7 +200,6 @@ jobs:
done < <(git diff --name-only --diff-filter=U)
if [ -z "$(git diff --name-only --diff-filter=U)" ]; then
- MERGE_STATUS="auto_resolved"
git commit -m "chore: merge stable into main (release conflicts auto-resolved)"
else
echo "Unresolved conflicts remain after version-only release artifact auto-resolution."
@@ -176,45 +209,10 @@ jobs:
fi
fi
- if ! git merge-base --is-ancestor origin/stable HEAD; then
+ if ! git merge-base --is-ancestor "$stable_sha" HEAD; then
echo "Stable is still not in the sync branch ancestry after merge."
exit 1
fi
- git push origin "$SYNC_BRANCH"
-
- # Check for existing open sync PR
- EXISTING=$(gh pr list --base main --head "$SYNC_BRANCH" --state open --json number -q '.[0].number' 2>/dev/null || true)
- if [ -n "$EXISTING" ]; then
- echo "Sync PR #$EXISTING already exists."
- exit 0
- fi
-
- gh pr create \
- --base main \
- --head "$SYNC_BRANCH" \
- --title "$PR_TITLE" \
- --body "$(cat <
The document engine for DOCX files.
Renders, edits, and automates .docx files in the browser, headless on the server, and within AI agent workflows.
- Self-hosted. Open source. Works with React, Vue, and vanilla JS.
+ Self-hosted. Open source. Works with React, Vue, Angular, Svelte, and vanilla JS.
diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs
index 067e8e7596..08525d89be 100644
--- a/apps/cli/.releaserc.cjs
+++ b/apps/cli/.releaserc.cjs
@@ -12,7 +12,7 @@ const {
*
* Keep in sync with .github/workflows/release-cli.yml paths: trigger.
*/
-require('../../scripts/semantic-release/patch-commit-filter.cjs')([
+const RELEASE_PATHS = [
'apps/cli',
'packages/document-api',
'packages/superdoc',
@@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/preset-geometry',
'shared',
'pnpm-workspace.yaml',
-]);
+];
+
+require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS);
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;
@@ -33,16 +35,28 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
-const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+const notesPlugin = isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc CLI',
+ paths: RELEASE_PATHS,
+ audience: 'Developers using the SuperDoc CLI to convert and process documents from the command line',
+ instructions:
+ 'This CLI wraps the document engine. Only mention engine changes when they change CLI commands, output, or supported document operations.',
+ },
+ },
+ ];
const config = {
branches,
@@ -89,13 +103,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-cli** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-cli** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/apps/create/.releaserc.cjs b/apps/create/.releaserc.cjs
index d9078f8021..67afe4662a 100644
--- a/apps/create/.releaserc.cjs
+++ b/apps/create/.releaserc.cjs
@@ -13,12 +13,10 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
@@ -49,13 +47,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/create** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/create** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/apps/docs/ai/agents/core-preset.mdx b/apps/docs/ai/agents/core-preset.mdx
index d695396ed4..67ec932ef8 100644
--- a/apps/docs/ai/agents/core-preset.mdx
+++ b/apps/docs/ai/agents/core-preset.mdx
@@ -350,9 +350,7 @@ Unknown action names in the list throw immediately (typo protection). The legacy
## 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.
-
+Use `defineAction` to add a named action with built-in `steps` or a native `run` function. `createAgentToolkit` keeps the resulting tool schema, system prompt, and dispatcher aligned. See [Custom actions](/ai/agents/custom-actions) for the SDK workflow and the optional coding-agent skill.
## Over MCP
diff --git a/apps/docs/ai/agents/custom-actions.mdx b/apps/docs/ai/agents/custom-actions.mdx
new file mode 100644
index 0000000000..a8aa783fd1
--- /dev/null
+++ b/apps/docs/ai/agents/custom-actions.mdx
@@ -0,0 +1,248 @@
+---
+title: Custom actions
+sidebarTitle: Custom actions
+description: "Add custom document actions to the core preset with defineAction and createAgentToolkit."
+keywords: "superdoc custom actions, defineAction, createAgentToolkit, extendPreset, composePreset, superdoc_perform_action, llm document actions, authoring skill"
+---
+
+The [`core` preset](/ai/agents/core-preset) ships 40 built-in actions. Add a custom action when your agent needs another named document operation. Custom actions run through `superdoc_perform_action` alongside the built-ins.
+
+You define custom actions in your application with the installed SDK. The toolkit adds them to the tool schema, system prompt, and dispatcher together.
+
+## Quick start
+
+Define an action, pass it to `createAgentToolkit`, then call the returned dispatcher. These examples assume your application already has an open document handle named `doc`.
+
+
+
+ ```typescript
+ import { createAgentToolkit, defineAction } from '@superdoc-dev/sdk';
+
+ const stampBanner = defineAction({
+ name: 'superdoc.stamp_banner',
+ description: 'Insert a banner at the top of the document.',
+ input: {
+ type: 'object',
+ properties: {
+ label: { type: 'string', default: 'CONFIDENTIAL' },
+ },
+ },
+ steps: [
+ {
+ action: 'insert_paragraphs',
+ args: {
+ texts: ['{{label}}'],
+ placement: { at: 'document_start' },
+ },
+ },
+ ],
+ });
+
+ const toolkit = await createAgentToolkit({
+ provider: 'openai',
+ actions: [stampBanner],
+ });
+
+ const receipt = await toolkit.dispatch(doc, 'superdoc_perform_action', {
+ action: 'superdoc.stamp_banner',
+ label: 'CONFIDENTIAL',
+ });
+ ```
+
+
+ ```python
+ from superdoc import create_agent_toolkit, define_action
+
+ stamp_banner = define_action(
+ name="superdoc.stamp_banner",
+ description="Insert a banner at the top of the document.",
+ input_schema={
+ "type": "object",
+ "properties": {
+ "label": {"type": "string", "default": "CONFIDENTIAL"},
+ },
+ },
+ steps=[
+ {
+ "action": "insert_paragraphs",
+ "args": {
+ "texts": ["{{label}}"],
+ "placement": {"at": "document_start"},
+ },
+ },
+ ],
+ )
+
+ toolkit = create_agent_toolkit({
+ "provider": "openai",
+ "actions": [stamp_banner],
+ })
+
+ receipt = toolkit["dispatch"](doc, "superdoc_perform_action", {
+ "action": "superdoc.stamp_banner",
+ "label": "CONFIDENTIAL",
+ })
+ ```
+
+
+
+Custom arguments are flat beside `action`. Do not nest them under an `args` property.
+
+`createAgentToolkit` builds an ephemeral preset over `core`. The returned tools, system prompt, and dispatcher use the same action surface. Provider shaping for Anthropic, OpenAI, Vercel, and generic tools is automatic.
+
+## Choose an execution tier
+
+`defineAction` returns an `ActionSpec`. Every action spec uses exactly one execution tier:
+
+| Tier | What it does | Use it when |
+| --- | --- | --- |
+| **`steps`** | Runs a sequence of built-in `core` actions | The built-ins already cover the operation. This keeps their targeting and verification behavior. |
+| **`run`** | Runs your function against the document handle | You need Document API operations or application logic that the built-ins do not provide. |
+
+Prefer `steps` when possible. In a step argument, a value that is exactly `{{label}}` passes the original argument value. Text around the placeholder interpolates it into a string.
+
+Use `run` for operations such as footnotes, headers, bookmarks, or table borders. The function runs in your application process.
+
+
+
+ ```typescript
+ import { defineAction } from '@superdoc-dev/sdk';
+
+ const listFootnotes = defineAction({
+ name: 'superdoc.list_footnotes',
+ description: 'List all footnotes in the document.',
+ input: { type: 'object', properties: {} },
+ run: (doc) => doc.footnotes.list({}),
+ });
+ ```
+
+
+ ```python
+ from superdoc import define_action
+
+ def _list_footnotes(doc, _args):
+ return doc.footnotes.list({})
+
+ list_footnotes = define_action(
+ name="superdoc.list_footnotes",
+ description="List all footnotes in the document.",
+ input_schema={"type": "object", "properties": {}},
+ run=_list_footnotes,
+ )
+ ```
+
+
+
+## Limit the built-in actions
+
+Pass `includeCoreActions` to keep only selected built-ins. Custom actions passed through `actions` remain available alongside that subset.
+
+```typescript
+import { createAgentToolkit } from '@superdoc-dev/sdk';
+
+const toolkit = await createAgentToolkit({
+ provider: 'openai',
+ includeCoreActions: ['insert_paragraphs', 'add_comments'],
+});
+```
+
+Python accepts the same option in the input dictionary: `"includeCoreActions": ["insert_paragraphs", "add_comments"]`.
+
+## Generate an action with a coding agent
+
+The `superdoc-custom-actions` skill guides Claude Code and Codex through choosing a tier, defining the action, wiring it into the toolkit, and testing it against a document.
+
+Install it from your application's root directory:
+
+```bash
+npx skills add superdoc-dev/superdoc/packages/sdk/skills/superdoc-custom-actions --copy
+```
+
+Then ask your coding agent for the operation you need:
+
+> Add a custom action that inserts a footnote after a piece of text.
+
+The skill instructs the agent to verify the action against a real document when possible. If the environment cannot provide a suitable document, the agent should label the action as unverified and include a test for the first real run.
+
+
`--copy` vendors the skill into your project. Add `-a ` if you want to install it for one supported coding agent only.
+
+## Python async clients
+
+If your application uses `AsyncSuperDocClient` or `dispatch_async`, a `run` action must use `async def` and await every `doc.*` call. A synchronous function receives coroutine values from the async document handle. `steps` actions do not require changes.
+
+## Reuse a named preset
+
+
+ Register a preset when several toolkit instances or standalone SDK functions need to resolve the same action set by ID.
+
+
+
+ ```typescript
+ import { createAgentToolkit, defineAction, extendPreset, registerPreset } from '@superdoc-dev/sdk';
+
+ const listFootnotes = defineAction({
+ name: 'superdoc.list_footnotes',
+ description: 'List all footnotes in the document.',
+ input: { type: 'object', properties: {} },
+ run: (doc) => doc.footnotes.list({}),
+ });
+
+ registerPreset(extendPreset('core', {
+ id: 'custom_superdoc_preset',
+ actions: [listFootnotes],
+ }));
+
+ const toolkit = await createAgentToolkit({
+ provider: 'openai',
+ preset: 'custom_superdoc_preset',
+ });
+ ```
+
+
+ ```python
+ from superdoc import create_agent_toolkit, define_action, extend_preset, register_preset
+
+ def _list_footnotes(doc, _args):
+ return doc.footnotes.list({})
+
+ list_footnotes = define_action(
+ name="superdoc.list_footnotes",
+ description="List all footnotes in the document.",
+ input_schema={"type": "object", "properties": {}},
+ run=_list_footnotes,
+ )
+
+ register_preset(extend_preset(
+ "core",
+ id="custom_superdoc_preset",
+ actions=[list_footnotes],
+ ))
+
+ toolkit = create_agent_toolkit({
+ "provider": "openai",
+ "preset": "custom_superdoc_preset",
+ })
+ ```
+
+
+
+ Registration is process-local. Run it during application startup before resolving the preset. Pass the preset ID to every preset-scoped standalone function. Common examples include:
+
+ - Node.js: `chooseTools`, `getToolCatalog`, `listTools`, `getSystemPrompt`, and `dispatchSuperDocTool`
+ - Python: `choose_tools`, `get_tool_catalog`, `list_tools`, `get_system_prompt`, `dispatch_superdoc_tool`, and `dispatch_superdoc_tool_async`
+
+ The dispatchers returned by `createAgentToolkit` and `create_agent_toolkit` are already bound to the preset.
+
+ Use `composePreset` or `compose_preset` instead when a named preset should keep only selected built-in actions.
+
+
+## Related
+
+
+
+ The 40 built-in actions your custom actions extend.
+
+
+ The toolkit, dispatch, and agent loop.
+
+
diff --git a/apps/docs/ai/agents/llm-tools.mdx b/apps/docs/ai/agents/llm-tools.mdx
index 444b0968cd..9d2e79a160 100644
--- a/apps/docs/ai/agents/llm-tools.mdx
+++ b/apps/docs/ai/agents/llm-tools.mdx
@@ -465,7 +465,7 @@ Core-preset receipts make the events meaningful for users: `action` names read l
Custom capabilities are documented per preset:
-- **Core preset** โ [Creating custom actions](/ai/agents/core-preset#creating-custom-actions): the `defineAction` authoring kit (coming soon).
+- **Core preset:** [Custom actions](/ai/agents/custom-actions) add named actions with `defineAction` and `createAgentToolkit`.
- **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
diff --git a/apps/docs/ai/agents/skills.mdx b/apps/docs/ai/agents/skills.mdx
index 50d7b62583..fcee99c829 100644
--- a/apps/docs/ai/agents/skills.mdx
+++ b/apps/docs/ai/agents/skills.mdx
@@ -1,15 +1,21 @@
---
title: Skills
sidebarTitle: Skills
-description: Reusable prompt templates that teach LLMs how to edit documents with SuperDoc tools
-keywords: "llm skills, prompt templates, ai document editing, superdoc skills"
+description: Install reusable instructions that help coding agents work with SuperDoc
+keywords: "coding agent skills, custom actions, claude code, codex, superdoc skills"
---
-Skills are reusable prompt files that teach LLMs how to use SuperDoc tools effectively. A skill contains editing instructions, tool usage patterns, and best practices: so your LLM agent knows how to query, mutate, and format documents without trial and error.
+SuperDoc skills are reusable instruction bundles for coding agents. They provide task-specific guidance, examples, and verification steps for working with the SDK.
-
-We haven't shipped skills yet. Follow the repo for updates.
-
+## Custom actions
+
+The `superdoc-custom-actions` skill helps Claude Code and Codex define, wire, and test custom LLM actions.
+
+```bash
+npx skills add superdoc-dev/superdoc/packages/sdk/skills/superdoc-custom-actions --copy
+```
+
+See [Custom actions](/ai/agents/custom-actions) for the SDK workflow, execution tiers, and installation details.
## Related
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 2b61a86d70..8b9252f7e9 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -201,6 +201,7 @@
"group": "Presets",
"pages": ["ai/agents/core-preset", "ai/agents/legacy-preset"]
},
+ "ai/agents/custom-actions",
"ai/agents/integrations",
"ai/agents/best-practices",
"ai/agents/debugging",
diff --git a/apps/docs/scripts/validate-ai-snippets.mjs b/apps/docs/scripts/validate-ai-snippets.mjs
index 4ee87a0142..71bb951e55 100644
--- a/apps/docs/scripts/validate-ai-snippets.mjs
+++ b/apps/docs/scripts/validate-ai-snippets.mjs
@@ -41,6 +41,7 @@ const PAGES = [
'ai/agents/integrations.mdx',
'ai/agents/best-practices.mdx',
'ai/agents/debugging.mdx',
+ 'ai/agents/custom-actions.mdx',
];
if (!existsSync(SDK_DIST)) {
diff --git a/apps/mcp/.releaserc.cjs b/apps/mcp/.releaserc.cjs
index 6eff113f91..71fc1ff905 100644
--- a/apps/mcp/.releaserc.cjs
+++ b/apps/mcp/.releaserc.cjs
@@ -14,7 +14,7 @@ const {
* Keep in sync with .github/workflows/release-mcp.yml paths and
* .github/package-impact-map.md.
*/
-require('../../scripts/semantic-release/patch-commit-filter.cjs')([
+const RELEASE_PATHS = [
'apps/mcp',
'packages/sdk',
'apps/cli',
@@ -26,7 +26,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/preset-geometry',
'shared',
'pnpm-workspace.yaml',
-]);
+];
+
+require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS);
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;
@@ -37,16 +39,28 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
-const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+const notesPlugin = isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc MCP',
+ paths: RELEASE_PATHS,
+ audience: 'Developers and AI agents using the SuperDoc MCP server tools',
+ instructions:
+ 'This server wraps the SDK and document engine as MCP tools. Only mention SDK or engine changes when they change tool behavior, schemas, or supported operations.',
+ },
+ },
+ ];
const config = {
branches,
@@ -92,13 +106,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/mcp** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/mcp** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/apps/vscode-ext/.releaserc.cjs b/apps/vscode-ext/.releaserc.cjs
index 503eb1649c..f1a0ae0cee 100644
--- a/apps/vscode-ext/.releaserc.cjs
+++ b/apps/vscode-ext/.releaserc.cjs
@@ -13,7 +13,7 @@ const {
*
* Keep in sync with .github/workflows/release-vscode-ext.yml paths: trigger.
*/
-require('../../scripts/semantic-release/patch-commit-filter.cjs')([
+const RELEASE_PATHS = [
'apps/vscode-ext',
'packages/superdoc',
'packages/super-editor',
@@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/preset-geometry',
'shared',
'pnpm-workspace.yaml',
-]);
+];
+
+require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS);
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;
@@ -33,16 +35,28 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags still proceed on main.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
-const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+const notesPlugin = isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc for VS Code',
+ paths: RELEASE_PATHS,
+ audience: 'VS Code users editing documents with the SuperDoc extension',
+ instructions:
+ 'This extension bundles the SuperDoc editor. Only mention editor/engine changes when they change what a VS Code user sees or can do inside the editor.',
+ },
+ },
+ ];
const config = {
branches,
@@ -65,16 +79,9 @@ const config = {
],
};
-// VS Code Marketplace doesn't support semver prerelease versions (e.g., 0.0.1-next.1)
-// Only publish stable releases to marketplace; prereleases get GitHub release with .vsix attached
-if (isPrerelease) {
- config.plugins.push([
- '@semantic-release/exec',
- {
- prepareCmd: 'pnpm run package', // Creates .vsix file only
- },
- ]);
-} else {
+// VS Code Marketplace doesn't support semver prerelease versions, so only
+// stable releases build and publish a .vsix.
+if (!isPrerelease) {
config.plugins.push([
'@semantic-release/exec',
{
@@ -103,14 +110,15 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- assets: [{ path: '*.vsix', label: 'VS Code Extension' }],
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **vscode-ext** v${nextRelease.version}',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ assets: [{ path: '*.vsix', label: 'VS Code Extension' }],
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **vscode-ext** v${nextRelease.version}',
+ },
+ ]);
+}
module.exports = config;
diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs
index 99641a0d19..2ab6f02d36 100644
--- a/packages/esign/.releaserc.cjs
+++ b/packages/esign/.releaserc.cjs
@@ -21,12 +21,10 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
@@ -64,13 +62,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **esign** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **esign** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/packages/fonts/.releaserc.cjs b/packages/fonts/.releaserc.cjs
index 88ec66b9b3..1d0d5b9fba 100644
--- a/packages/fonts/.releaserc.cjs
+++ b/packages/fonts/.releaserc.cjs
@@ -14,7 +14,8 @@ const branches = [
];
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-const shouldCommentOnRelease = !isPrerelease;
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
const shouldCommentOnLinearRelease = true;
const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
@@ -49,13 +50,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/fonts** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/fonts** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs
index 0914f533f1..a6e95457fc 100644
--- a/packages/react/.releaserc.cjs
+++ b/packages/react/.releaserc.cjs
@@ -13,7 +13,7 @@ const {
* When react migrates `superdoc` to peerDependencies, narrow this to
* packages/react only. See .github/package-impact-map.md.
*/
-require('../../scripts/semantic-release/patch-commit-filter.cjs')([
+const RELEASE_PATHS = [
'packages/react',
'packages/superdoc',
'packages/super-editor',
@@ -22,7 +22,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/preset-geometry',
'shared',
'pnpm-workspace.yaml',
-]);
+];
+
+require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS);
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;
@@ -33,16 +35,28 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
-const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+const notesPlugin = isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc React',
+ paths: RELEASE_PATHS,
+ audience: 'React developers embedding the @superdoc-dev/react component',
+ instructions:
+ "This package wraps the SuperDoc editor for React. Only mention editor changes when they affect the embedded editor's behavior or the component's props and API.",
+ },
+ },
+ ];
const config = {
branches,
@@ -82,13 +96,14 @@ config.plugins.push([
{ teamKeys: ['SD'], addComment: shouldCommentOnLinearRelease, packageName: 'react' },
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/react** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/react** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs
index d051242492..968c28446a 100644
--- a/packages/sdk/.releaserc.cjs
+++ b/packages/sdk/.releaserc.cjs
@@ -10,7 +10,7 @@ const {
* This shared helper patches git-log-parser to expand commit analysis to
* dependency paths. It REPLACES semantic-release-commit-filter.
*/
-require('../../scripts/semantic-release/patch-commit-filter.cjs')([
+const RELEASE_PATHS = [
'packages/sdk',
'apps/cli',
'packages/document-api',
@@ -21,7 +21,9 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([
'packages/preset-geometry',
'shared',
'pnpm-workspace.yaml',
-]);
+];
+
+require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS);
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;
const isCiRelease = Boolean(process.env.CI);
@@ -33,16 +35,29 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
-const notesPlugin = isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+const notesPlugin = isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc SDK',
+ paths: RELEASE_PATHS,
+ audience:
+ 'Developers integrating the SuperDoc SDK (Node and Python bindings) into their own backend or CLI tooling',
+ instructions:
+ "The SDK wraps the CLI and document engine. Only mention CLI or engine changes when they change the SDK's API, output, or supported operations.",
+ },
+ },
+ ];
const config = {
branches,
@@ -116,13 +131,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-sdk** v${nextRelease.version}',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc-sdk** v${nextRelease.version}',
+ },
+ ]);
+}
module.exports = config;
diff --git a/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts b/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts
new file mode 100644
index 0000000000..2aed62564f
--- /dev/null
+++ b/packages/sdk/langs/node/src/__tests__/custom-actions.e2e.test.ts
@@ -0,0 +1,138 @@
+/**
+ * Custom-action END-TO-END test against the real CLI host.
+ *
+ * Registers an `extendPreset('core', { id:'acme', actions: footnoteActions })`
+ * preset, opens a real .docx, and dispatches `footnotes.add` then
+ * `footnotes.list` as run-tier custom actions. Proves the whole path โ
+ * dispatch โ run(doc, args) โ doc.footnotes.* over JSON-RPC โ receipt โ works
+ * with ZERO CLI host changes.
+ *
+ * Requires the spawned host (SUPERDOC_CLI_BIN โ apps/cli/src/index.ts, run via
+ * bun). Skips nothing โ if the host cannot start, the test fails loudly.
+ */
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import path from 'node:path';
+import { createSuperDocClient } from '../index.ts';
+import { registerPreset, unregisterPreset } from '../tools.ts';
+import { dispatchSuperDocTool, chooseTools, createAgentToolkit } from '../tools.ts';
+import { extendPreset } from '../actions/define.ts';
+import { footnoteActions } from './fixtures/footnotes.ts';
+
+// packages/sdk/langs/node/src/__tests__ โ repo root
+const REPO_ROOT = path.resolve(import.meta.dir, '../../../../../..');
+const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts');
+const FIXTURE_DOC = path.join(REPO_ROOT, 'packages/super-editor/src/editors/v1/tests/data/advanced-text.docx');
+
+const E2E_TIMEOUT_MS = 60_000;
+
+describe('custom footnote actions (e2e)', () => {
+ beforeAll(() => {
+ registerPreset(extendPreset('core', { id: 'acme', actions: footnoteActions }));
+ });
+ afterAll(() => {
+ try {
+ unregisterPreset('acme');
+ } catch {
+ // ignore
+ }
+ });
+
+ test('footnote action names appear in the acme superdoc_perform_action enum', async () => {
+ const { tools } = await chooseTools({ provider: 'anthropic', preset: 'acme' });
+ const actionTool = tools.find((t) => (t as { name?: string }).name === 'superdoc_perform_action') as
+ | { input_schema?: { properties?: { action?: { enum?: string[] } } } }
+ | undefined;
+ const names = actionTool?.input_schema?.properties?.action?.enum ?? [];
+ for (const r of footnoteActions) expect(names).toContain(r.name);
+ });
+
+ test(
+ 'dispatch footnotes.add then footnotes.list shows the inserted note',
+ async () => {
+ const client = createSuperDocClient({
+ env: { SUPERDOC_CLI_BIN: CLI_BIN },
+ });
+ try {
+ await client.connect();
+ const doc = await client.open({ doc: FIXTURE_DOC });
+
+ // Find a body paragraph block to anchor the footnote on.
+ const blocks = (await doc.blocks.list({ limit: 50, includeText: true })) as {
+ blocks: Array<{ nodeId: string; nodeType: string; text?: string }>;
+ };
+ const para = blocks.blocks.find((b) => b.nodeType === 'paragraph' && (b.text?.length ?? 0) > 0);
+ expect(para).toBeDefined();
+ const at = {
+ kind: 'text',
+ segments: [{ blockId: para!.nodeId, range: { start: 0, end: 0 } }],
+ };
+
+ const addReceipt = (await dispatchSuperDocTool(
+ doc,
+ 'superdoc_perform_action',
+ { action: 'footnotes.add', at, content: 'Inserted by the footnotes.add custom action.' },
+ { preset: 'acme' },
+ )) as { status: string; action: string; result?: unknown };
+
+ expect(addReceipt.action).toBe('footnotes.add');
+ expect(addReceipt.status).toBe('succeeded');
+
+ const listReceipt = (await dispatchSuperDocTool(
+ doc,
+ 'superdoc_perform_action',
+ { action: 'footnotes.list' },
+ { preset: 'acme' },
+ )) as { status: string; result?: { items?: unknown[] } };
+
+ expect(listReceipt.status).toBe('succeeded');
+ const items = listReceipt.result?.items ?? [];
+ expect(Array.isArray(items)).toBe(true);
+ expect(items.length).toBeGreaterThanOrEqual(1);
+
+ await doc.close({ discard: true });
+ } finally {
+ await client.dispose();
+ }
+ },
+ E2E_TIMEOUT_MS,
+ );
+
+ test(
+ 'a custom action executes through the one-call createAgentToolkit dispatch',
+ async () => {
+ // The whole point of the review item: build the toolkit from `actions`
+ // and run a custom action through the RETURNED dispatch (no preset id).
+ const kit = await createAgentToolkit({ provider: 'anthropic', actions: footnoteActions });
+ const client = createSuperDocClient({ env: { SUPERDOC_CLI_BIN: CLI_BIN } });
+ try {
+ await client.connect();
+ const doc = await client.open({ doc: FIXTURE_DOC });
+ const blocks = (await doc.blocks.list({ limit: 50, includeText: true })) as {
+ blocks: Array<{ nodeId: string; nodeType: string; text?: string }>;
+ };
+ const para = blocks.blocks.find((b) => b.nodeType === 'paragraph' && (b.text?.length ?? 0) > 0);
+ expect(para).toBeDefined();
+ const at = { kind: 'text', segments: [{ blockId: para!.nodeId, range: { start: 0, end: 0 } }] };
+
+ const addReceipt = (await kit.dispatch(doc, 'superdoc_perform_action', {
+ action: 'footnotes.add',
+ at,
+ content: 'Inserted via the one-call toolkit dispatch.',
+ })) as { status: string; action: string };
+ expect(addReceipt.action).toBe('footnotes.add');
+ expect(addReceipt.status).toBe('succeeded');
+
+ const listReceipt = (await kit.dispatch(doc, 'superdoc_perform_action', {
+ action: 'footnotes.list',
+ })) as { status: string; result?: { items?: unknown[] } };
+ expect(listReceipt.status).toBe('succeeded');
+ expect((listReceipt.result?.items ?? []).length).toBeGreaterThanOrEqual(1);
+
+ await doc.close({ discard: true });
+ } finally {
+ await client.dispose();
+ }
+ },
+ E2E_TIMEOUT_MS,
+ );
+});
diff --git a/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts b/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts
new file mode 100644
index 0000000000..b814171de4
--- /dev/null
+++ b/packages/sdk/langs/node/src/__tests__/custom-actions.test.ts
@@ -0,0 +1,1130 @@
+/**
+ * Custom-action tests.
+ *
+ * Most run WITHOUT the CLI host binary: they use a fake base preset to assert
+ * the merged tool list / catalog / prompt, exclusion coherence, tier routing,
+ * and receipt shapes. The run-tier fixture (footnotes) exercises the same
+ * surfaces the e2e test drives against the real host.
+ */
+import { afterEach, describe, expect, test } from 'bun:test';
+import type { BoundDocApi } from '../generated/client.ts';
+import { composePreset, defineAction, executionKindOf, extendPreset } from '../actions/define.ts';
+import { footnoteActions } from './fixtures/footnotes.ts';
+import { ACTION_NAMES_LIST } from '../agent/actions.ts';
+import { chooseTools, createAgentToolkit, dispatchSuperDocTool, getToolCatalog, getSystemPrompt } from '../tools.ts';
+import { getPreset, listPresets, registerPreset, unregisterPreset } from '../presets.ts';
+import { SuperDocCliError } from '../runtime/errors.js';
+
+const REGISTERED: string[] = [];
+afterEach(() => {
+ for (const id of REGISTERED.splice(0)) {
+ try {
+ unregisterPreset(id);
+ } catch {
+ // ignore
+ }
+ }
+});
+
+function actionProp(tool: unknown): { enum?: string[] } | undefined {
+ const t = tool as {
+ parameters?: { properties?: Record
};
+ input_schema?: { properties?: Record };
+ function?: { parameters?: { properties?: Record } };
+ };
+ const props = t.function?.parameters?.properties ?? t.parameters?.properties ?? t.input_schema?.properties ?? {};
+ return props.action as { enum?: string[] } | undefined;
+}
+
+function schemaOf(tool: unknown): { properties?: Record } | undefined {
+ const t = tool as {
+ parameters?: { properties?: Record };
+ input_schema?: { properties?: Record };
+ function?: { parameters?: { properties?: Record } };
+ };
+ return t.function?.parameters ?? t.parameters ?? t.input_schema;
+}
+
+function toolName(tool: unknown): string {
+ const t = tool as { name?: string; function?: { name?: string } };
+ return t.function?.name ?? t.name ?? '';
+}
+
+describe('defineAction โ canonical type, two tiers', () => {
+ test('run tier keeps the native function (NOT serialized)', () => {
+ const run = (_doc: unknown, args: Record) => args;
+ const spec = defineAction({
+ name: 'demo.echo',
+ description: 'echo',
+ input: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] },
+ run,
+ });
+ expect(spec.name).toBe('demo.echo');
+ expect(spec.run).toBe(run);
+ expect(spec.inputSchema.required).toEqual(['x']);
+ });
+
+ test('steps tier normalizes steps and validates against built-ins', () => {
+ const spec = defineAction({
+ name: 'demo.steps',
+ description: 'd',
+ input: { type: 'object', properties: { label: { type: 'string', default: 'X' } } },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }],
+ });
+ expect(spec.steps).toEqual([{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }]);
+ expect(executionKindOf(spec)).toBe('steps');
+ });
+
+ test('steps referencing a non-built-in action are rejected at define time', () => {
+ expect(() =>
+ defineAction({
+ name: 'demo.badstep',
+ description: 'd',
+ steps: [{ action: 'not_a_real_action', args: {} }],
+ }),
+ ).toThrow(SuperDocCliError);
+ });
+
+ test('requires exactly one execution tier', () => {
+ expect(() => defineAction({ name: 'demo.none', description: 'd' } as never)).toThrow(SuperDocCliError);
+ expect(() =>
+ defineAction({
+ name: 'demo.two',
+ description: 'd',
+ run: () => null,
+ steps: [{ action: 'insert_paragraphs' }],
+ } as never),
+ ).toThrow(SuperDocCliError);
+ });
+});
+
+describe('extendPreset โ tool merging', () => {
+ test('footnote action names appear in superdoc_perform_action enum (anthropic)', async () => {
+ const acme = extendPreset('core', { id: 'acme', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme');
+ const { tools, meta } = await chooseTools({ provider: 'anthropic', preset: 'acme' });
+ expect(meta.preset).toBe('acme');
+ const actionTool = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ expect(actionTool).toBeDefined();
+ const names = actionProp(actionTool)?.enum ?? [];
+ for (const r of footnoteActions) expect(names).toContain(r.name);
+ // built-in actions are still present
+ expect(names).toContain('insert_paragraphs');
+ // union'd properties: noteId/content come from footnote actions
+ const props =
+ (actionTool as { input_schema?: { properties?: Record } }).input_schema?.properties ?? {};
+ expect(props.noteId).toBeDefined();
+ expect(props.content).toBeDefined();
+ });
+
+ test('open object schema without `properties` merges without throwing', async () => {
+ // A legal open schema that omits `properties` (e.g. `{ type: 'object',
+ // additionalProperties: true }`) must not crash getTools for the WHOLE
+ // preset. coerceInputSchema normalizes the missing bag to {}, and the merge
+ // loop guards with `?? {}` to match its twin (synthesizePerformAction) and
+ // the Python mirror.
+ const open = defineAction({
+ name: 'demo.open',
+ description: 'open schema, no properties',
+ input: { type: 'object', additionalProperties: true },
+ run: (_doc, args) => args,
+ });
+ expect(open.inputSchema.properties).toEqual({});
+ const acme = extendPreset('core', { id: 'acme-open', actions: [open] });
+ registerPreset(acme);
+ REGISTERED.push('acme-open');
+ const { tools } = await chooseTools({ provider: 'anthropic', preset: 'acme-open' });
+ const actionTool = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ expect(actionTool).toBeDefined();
+ const names = actionProp(actionTool)?.enum ?? [];
+ expect(names).toContain('demo.open');
+ });
+
+ test('standalone:true exposes each action as its own provider tool', async () => {
+ // Standalone tool names become provider tool names, so they must be
+ // provider-safe (no dots). Use underscore-named variants of the footnotes.
+ const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') }));
+ const acme = extendPreset('core', { id: 'acme-standalone', actions: safe, standalone: true });
+ registerPreset(acme);
+ REGISTERED.push('acme-standalone');
+ const { tools } = await chooseTools({ provider: 'openai', preset: 'acme-standalone' });
+ const names = tools.map(toolName);
+ for (const r of safe) expect(names).toContain(r.name);
+ const fnAdd = tools.find((t) => toolName(t) === 'footnotes_add') as {
+ type?: string;
+ function?: { parameters?: unknown };
+ };
+ expect(fnAdd.type).toBe('function');
+ expect(fnAdd.function?.parameters).toBeDefined();
+ });
+
+ test('standalone vercel tools use the FLAT core agent dialect', async () => {
+ const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') }));
+ const acme = extendPreset('core', { id: 'acme-vercel', actions: safe, standalone: true });
+ registerPreset(acme);
+ REGISTERED.push('acme-vercel');
+ const { tools } = await chooseTools({ provider: 'vercel', preset: 'acme-vercel' });
+ const fnAdd = tools.find((t) => toolName(t) === 'footnotes_add') as {
+ type?: string;
+ inputSchema?: unknown;
+ function?: unknown;
+ };
+ // Core's vercel dialect is flat {name, description, inputSchema} โ the
+ // nested OpenAI shape here would be invisible to Vercel AI SDK callers.
+ expect(fnAdd.function).toBeUndefined();
+ expect(fnAdd.type).toBeUndefined();
+ expect(fnAdd.inputSchema).toBeDefined();
+ });
+
+ test('merged vercel advertises custom actions in the flat-dialect enum', async () => {
+ const acme = extendPreset('core', { id: 'acme-vercel-merged', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme-vercel-merged');
+ const { tools } = await chooseTools({ provider: 'vercel', preset: 'acme-vercel-merged' });
+ const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action') as {
+ inputSchema?: { properties?: { action?: { enum?: string[] } } };
+ };
+ expect(perform.inputSchema?.properties?.action?.enum).toContain('footnotes.add');
+ });
+
+ test('excludeActions narrows custom AND builtin actions coherently', async () => {
+ const acme = extendPreset('core', { id: 'acme-excl', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme-excl');
+ const excludeActions = ['footnotes.add', 'create_table'];
+ const { tools } = await chooseTools({ provider: 'openai', preset: 'acme-excl', excludeActions });
+ const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action') as {
+ function?: { parameters?: { properties?: { action?: { enum?: string[] } } } };
+ };
+ const names = perform.function?.parameters?.properties?.action?.enum ?? [];
+ expect(names).not.toContain('footnotes.add'); // custom excluded by the wrapper
+ expect(names).not.toContain('create_table'); // builtin excluded by the base
+ expect(names).toContain('footnotes.list'); // other customs survive
+
+ const prompt = await getSystemPrompt('acme-excl', { excludeActions });
+ expect(prompt).not.toContain('- footnotes.add โ');
+ expect(prompt).toContain('footnotes.list');
+
+ // Defense-in-depth: dispatching the excluded CUSTOM action is refused.
+ await expect(
+ dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'footnotes.add', at: {}, content: 'x' },
+ { preset: 'acme-excl', excludeActions } as never,
+ ),
+ ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT', details: { excluded: true } });
+ });
+
+ test('standalone mode does NOT route customs through perform_action (unadvertised path)', async () => {
+ const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') }));
+ const acme = extendPreset('core', { id: 'acme-standalone-gate', actions: safe, standalone: true });
+ registerPreset(acme);
+ REGISTERED.push('acme-standalone-gate');
+ // The standalone surface advertises footnotes_add as its OWN tool, so the
+ // perform_action route must fall through to the base, which rejects it.
+ await expect(
+ dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'footnotes_add', at: {}, content: 'x' },
+ { preset: 'acme-standalone-gate' },
+ ),
+ ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
+ });
+
+ test('standalone action keeps an `action`-named argument (not treated as discriminator)', async () => {
+ let received: Record | undefined;
+ const spec = defineAction({
+ name: 'wf_trigger',
+ description: 'run a workflow step',
+ input: {
+ type: 'object',
+ properties: { action: { type: 'string', enum: ['approve', 'reject'] } },
+ required: ['action'],
+ },
+ run: (_doc, args) => {
+ received = args;
+ return { ok: true };
+ },
+ });
+ const acme = extendPreset('core', { id: 'acme-standalone-arg', actions: [spec], standalone: true });
+ registerPreset(acme);
+ REGISTERED.push('acme-standalone-arg');
+ // Dispatched as its OWN tool: `action` is a real arg, must survive.
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'wf_trigger',
+ { action: 'approve' },
+ { preset: 'acme-standalone-arg' },
+ )) as { status: string };
+ expect(receipt.status).toBe('succeeded');
+ expect(received).toEqual({ action: 'approve' });
+ });
+
+ test('standalone rejects dotted action names; merged accepts them', () => {
+ // Dotted name is invalid as a provider tool name (standalone), valid as an
+ // enum value (merged).
+ expect(() => extendPreset('core', { id: 'bad-standalone', actions: footnoteActions, standalone: true })).toThrow(
+ SuperDocCliError,
+ );
+ expect(() => extendPreset('core', { id: 'ok-merged', actions: footnoteActions })).not.toThrow();
+ });
+
+ test('anthropic cache:true marks exactly the LAST tool after standalone append', async () => {
+ const safe = footnoteActions.map((r) => ({ ...r, name: r.name.replace('.', '_') }));
+ const acme = extendPreset('core', { id: 'acme-cache', actions: safe, standalone: true });
+ registerPreset(acme);
+ REGISTERED.push('acme-cache');
+ const { tools, meta } = await chooseTools({ provider: 'anthropic', preset: 'acme-cache', cache: true });
+ expect(meta.cacheStrategy).toBe('explicit');
+ const withMarker = tools.filter((t) => (t as Record).cache_control != null);
+ expect(withMarker.length).toBe(1);
+ // and it must be the final tool in the list
+ expect((tools[tools.length - 1] as Record).cache_control).toEqual({ type: 'ephemeral' });
+ });
+
+ test('catalog and system prompt include custom actions', async () => {
+ const acme = extendPreset('core', { id: 'acme-cat', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme-cat');
+ const catalog = await getToolCatalog('acme-cat');
+ const row = catalog.tools.find((t) => t.toolName === 'footnotes.add');
+ expect(row).toBeDefined();
+ expect(row?.mutates).toBe(true);
+ expect(row?.operations).toEqual([]);
+ const prompt = await getSystemPrompt('acme-cat');
+ expect(prompt).toContain('## Custom actions');
+ expect(prompt).toContain('footnotes.add');
+ });
+});
+
+describe('collision / duplicate validation', () => {
+ test('rejects a custom action named after a reserved tool', () => {
+ const bad = defineAction({ name: 'superdoc_execute_code', description: 'x', run: () => null });
+ expect(() => extendPreset('core', { id: 'bad-reserved', actions: [bad], standalone: true })).toThrow(
+ SuperDocCliError,
+ );
+ });
+
+ test('explicit null for an optional enum arg is rejected (a value, not an absence)', async () => {
+ const spec = defineAction({
+ name: 'acme.enumnull',
+ description: 'd',
+ input: { type: 'object', properties: { mode: { type: 'string', enum: ['a', 'b'], default: 'a' } } },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }],
+ });
+ const acme = extendPreset('core', { id: 'acme-enumnull', actions: [spec] });
+ registerPreset(acme);
+ REGISTERED.push('acme-enumnull');
+ await expect(
+ dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.enumnull', mode: null },
+ { preset: 'acme-enumnull' },
+ ),
+ ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
+ });
+
+ test('required arg with a declared default is satisfied by the default', async () => {
+ const calls: Array> = [];
+ registerPreset({
+ id: 'req-default-base',
+ description: 'f',
+ supportsCacheControl: true,
+ getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }),
+ getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }),
+ getSystemPrompt: async () => 'b',
+ getMcpPrompt: async () => 'b',
+ dispatch: async (_h: BoundDocApi, _t: string, args: Record) => {
+ calls.push(args);
+ return { status: 'ok' };
+ },
+ });
+ REGISTERED.push('req-default-base');
+ const spec = defineAction({
+ name: 'acme.reqdef',
+ description: 'd',
+ input: { type: 'object', properties: { label: { type: 'string', default: 'D' } }, required: ['label'] },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }],
+ });
+ const acme = extendPreset('req-default-base', { id: 'acme-reqdef', actions: [spec] });
+ registerPreset(acme);
+ REGISTERED.push('acme-reqdef');
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.reqdef' }, // label omitted โ default must satisfy `required`
+ { preset: 'acme-reqdef' },
+ )) as { status: string };
+ expect(receipt.status).toBe('succeeded');
+ expect(calls[0]!.texts).toEqual(['D']);
+ });
+
+ test('rejects a custom action colliding with a built-in name', () => {
+ const builtin = ACTION_NAMES_LIST[0]!;
+ const bad = defineAction({ name: builtin, description: 'x', run: () => null });
+ expect(() => extendPreset('core', { id: 'bad', actions: [bad] })).toThrow(SuperDocCliError);
+ });
+
+ test('rejects duplicate custom names', () => {
+ const a = defineAction({ name: 'dup.one', description: 'a', run: () => null });
+ const b = defineAction({ name: 'dup.one', description: 'b', run: () => null });
+ expect(() => extendPreset('core', { id: 'dup', actions: [a, b] })).toThrow(SuperDocCliError);
+ });
+});
+
+describe('registerPreset / unregisterPreset', () => {
+ test('cannot overwrite a built-in preset id', () => {
+ const fake = extendPreset('core', { id: 'will-rename', actions: [] });
+ const asCore = { ...fake, id: 'core' };
+ expect(() => registerPreset(asCore)).toThrow(SuperDocCliError);
+ });
+
+ test('cannot unregister a built-in', () => {
+ expect(() => unregisterPreset('core')).toThrow(SuperDocCliError);
+ });
+
+ test('register then resolve then unregister', () => {
+ const p = extendPreset('core', { id: 'tmp-preset', actions: footnoteActions });
+ registerPreset(p);
+ expect(getPreset('tmp-preset').id).toBe('tmp-preset');
+ unregisterPreset('tmp-preset');
+ expect(() => getPreset('tmp-preset')).toThrow(SuperDocCliError);
+ });
+});
+
+describe('dispatch โ validation + delegation (tier-agnostic)', () => {
+ test('missing required arg throws INVALID_ARGUMENT before dispatch', async () => {
+ const acme = extendPreset('core', { id: 'acme-validate', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme-validate');
+ await expect(
+ dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'footnotes.add', content: 'x' },
+ { preset: 'acme-validate' },
+ ),
+ ).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
+ });
+
+ test('a non-custom action delegates to the base dispatch', async () => {
+ let delegated = false;
+ registerPreset({
+ id: 'fake-delegate',
+ description: 'f',
+ supportsCacheControl: true,
+ getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }),
+ getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }),
+ getSystemPrompt: async () => 'b',
+ getMcpPrompt: async () => 'b',
+ dispatch: async (_h: BoundDocApi, tool: string, args: Record) => {
+ delegated = true;
+ return { delegatedTool: tool, action: args.action };
+ },
+ });
+ REGISTERED.push('fake-delegate');
+ const acme = extendPreset('fake-delegate', { id: 'acme-delegate', actions: footnoteActions });
+ registerPreset(acme);
+ REGISTERED.push('acme-delegate');
+ const result = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'insert_paragraphs', text: 'hi' },
+ { preset: 'acme-delegate' },
+ )) as { delegatedTool: string };
+ expect(delegated).toBe(true);
+ expect(result.delegatedTool).toBe('superdoc_perform_action');
+ });
+});
+
+describe('dispatch โ steps tier', () => {
+ /** Fake base that records perform_action step dispatches and returns scripted receipts. */
+ function stepsBase(id: string, receipts: Array | Error>) {
+ const calls: Array> = [];
+ let cursor = 0;
+ registerPreset({
+ id,
+ description: 'fake steps base',
+ supportsCacheControl: true,
+ getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }),
+ getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }),
+ getSystemPrompt: async () => 'base',
+ getMcpPrompt: async () => 'base',
+ dispatch: async (_h: BoundDocApi, tool: string, args: Record) => {
+ expect(tool).toBe('superdoc_perform_action');
+ calls.push(args);
+ const next = receipts[Math.min(cursor, receipts.length - 1)]!;
+ cursor += 1;
+ if (next instanceof Error) throw next;
+ return next;
+ },
+ });
+ REGISTERED.push(id);
+ return calls;
+ }
+
+ const stamp = defineAction({
+ name: 'acme.stamp',
+ description: 'banner + comment',
+ input: {
+ type: 'object',
+ properties: { label: { type: 'string', default: 'CONFIDENTIAL' } },
+ },
+ steps: [
+ { action: 'insert_paragraphs', args: { texts: ['{{label}}'], placement: { at: 'document_start' } } },
+ {
+ action: 'add_comments',
+ args: {
+ selectors: [{ kind: 'textSearch', terms: ['{{label}}'] }],
+ commentText: 'Stamped: {{label}} โ verify.',
+ },
+ },
+ ],
+ });
+
+ test('dispatches each step through the base with templating + defaults applied', async () => {
+ const calls = stepsBase('steps-ok', [{ status: 'ok', verificationPassed: true }]);
+ const acme = extendPreset('steps-ok', { id: 'acme-steps', actions: [stamp] });
+ registerPreset(acme);
+ REGISTERED.push('acme-steps');
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.stamp' }, // label omitted โ default applies
+ { preset: 'acme-steps' },
+ )) as { status: string; steps: Array> };
+
+ expect(receipt.status).toBe('succeeded');
+ expect(receipt.steps).toHaveLength(2);
+ // Whole-string template preserved the raw array value; default filled in.
+ expect(calls[0]).toEqual({
+ action: 'insert_paragraphs',
+ texts: ['CONFIDENTIAL'],
+ placement: { at: 'document_start' },
+ });
+ // Partial template interpolated as text.
+ expect(calls[1]!.commentText).toBe('Stamped: CONFIDENTIAL โ verify.');
+ expect(calls[1]!.selectors).toEqual([{ kind: 'textSearch', terms: ['CONFIDENTIAL'] }]);
+ });
+
+ test('caller-level changeMode reaches steps that do not pin their own', async () => {
+ const calls = stepsBase('steps-cm', [{ status: 'ok' }]);
+ const acme = extendPreset('steps-cm', { id: 'acme-steps-cm', actions: [stamp] });
+ registerPreset(acme);
+ REGISTERED.push('acme-steps-cm');
+ await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.stamp', label: 'X', changeMode: 'tracked' },
+ { preset: 'acme-steps-cm' },
+ );
+ expect(calls[0]!.changeMode).toBe('tracked');
+ expect(calls[1]!.changeMode).toBe('tracked');
+ });
+
+ test('surface excludeActions are NOT forwarded into internal step dispatch', async () => {
+ // Base that simulates core's defense-in-depth: refuse any call whose action
+ // is in the invoke-time excludeActions. If a surface exclusion leaked into
+ // the steps, the insert_paragraphs step would be refused and acme.stamp
+ // (advertised!) would fail โ the coherence bug this guards against.
+ const seenOptions: Array<{ excludeActions?: unknown } | undefined> = [];
+ registerPreset({
+ id: 'steps-excl-base',
+ description: 'fake base enforcing invoke-time exclusions',
+ supportsCacheControl: true,
+ getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }),
+ getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }),
+ getSystemPrompt: async () => 'base',
+ getMcpPrompt: async () => 'base',
+ dispatch: async (_h: BoundDocApi, _tool: string, args: Record, invokeOptions?: unknown) => {
+ const opts = invokeOptions as { excludeActions?: string[] } | undefined;
+ seenOptions.push(opts);
+ if (opts?.excludeActions?.includes(args.action as string)) {
+ throw new SuperDocCliError(`Action ${String(args.action)} is excluded by configuration.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { excluded: true },
+ });
+ }
+ return { status: 'ok', verificationPassed: true };
+ },
+ });
+ REGISTERED.push('steps-excl-base');
+ const acme = extendPreset('steps-excl-base', { id: 'acme-steps-excl', actions: [stamp] });
+ registerPreset(acme);
+ REGISTERED.push('acme-steps-excl');
+
+ // Hide insert_paragraphs from the model, but acme.stamp composes it.
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.stamp' },
+ { preset: 'acme-steps-excl', excludeActions: ['insert_paragraphs'] } as never,
+ )) as { status: string; steps: unknown[] };
+ expect(receipt.status).toBe('succeeded'); // step NOT refused
+ expect(receipt.steps).toHaveLength(2);
+ // Every internal step dispatch saw options WITHOUT excludeActions.
+ expect(seenOptions.every((o) => o?.excludeActions === undefined)).toBe(true);
+
+ // Guard preserved: a DIRECT model call to the hidden built-in is still refused.
+ await expect(
+ dispatchSuperDocTool({} as BoundDocApi, 'superdoc_perform_action', { action: 'insert_paragraphs', text: 'x' }, {
+ preset: 'acme-steps-excl',
+ excludeActions: ['insert_paragraphs'],
+ } as never),
+ ).rejects.toThrow(/excluded/);
+ });
+
+ test('second-step failure aggregates to partial with failedStep evidence', async () => {
+ stepsBase('steps-fail2', [{ status: 'ok' }, { status: 'failed', errors: [{ message: 'no match' }] }]);
+ const acme = extendPreset('steps-fail2', { id: 'acme-steps-f2', actions: [stamp] });
+ registerPreset(acme);
+ REGISTERED.push('acme-steps-f2');
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.stamp' },
+ { preset: 'acme-steps-f2' },
+ )) as { status: string; failedStep?: { index: number } };
+ expect(receipt.status).toBe('partial');
+ expect(receipt.failedStep?.index).toBe(1);
+ });
+
+ test('first-step thrown validation error aggregates to failed', async () => {
+ stepsBase('steps-throw', [new SuperDocCliError('bad args', { code: 'INVALID_ARGUMENT' })]);
+ const acme = extendPreset('steps-throw', { id: 'acme-steps-throw', actions: [stamp] });
+ registerPreset(acme);
+ REGISTERED.push('acme-steps-throw');
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.stamp' },
+ { preset: 'acme-steps-throw' },
+ )) as { status: string; steps: Array<{ status: string }> };
+ expect(receipt.status).toBe('failed');
+ expect(receipt.steps[0]!.status).toBe('failed');
+ });
+});
+
+describe('dispatch โ steps tier extras', () => {
+ const parity = defineAction({
+ name: 'acme.parity',
+ description: 'd',
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['flag={{flag}} items={{items}} obj={{obj}}'] } }],
+ });
+ const arrmiss = defineAction({
+ name: 'acme.arrmiss',
+ description: 'd',
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['{{present}}', '{{absent}}'] } }],
+ });
+
+ function stepsBase2(id: string, receipts: Array>) {
+ const calls: Array> = [];
+ let cursor = 0;
+ registerPreset({
+ id,
+ description: 'fake',
+ supportsCacheControl: true,
+ getTools: async () => ({ tools: [], cacheStrategy: 'disabled' as const }),
+ getCatalog: async () => ({ contractVersion: 'x', generatedAt: null, toolCount: 0, tools: [] }),
+ getSystemPrompt: async () => 'base',
+ getMcpPrompt: async () => 'base',
+ dispatch: async (_h: BoundDocApi, _tool: string, args: Record) => {
+ calls.push(args);
+ const next = receipts[Math.min(cursor, receipts.length - 1)]!;
+ cursor += 1;
+ return next;
+ },
+ });
+ REGISTERED.push(id);
+ return calls;
+ }
+
+ test('a PARTIAL step never rolls up into succeeded', async () => {
+ stepsBase2('steps-partial', [{ status: 'ok' }, { status: 'partial' }]);
+ const spec = defineAction({
+ name: 'acme.twostep',
+ description: 'd',
+ steps: [
+ { action: 'insert_paragraphs', args: { texts: ['a'] } },
+ { action: 'add_comments', args: { selectors: [], commentText: 'c' } },
+ ],
+ });
+ const acme = extendPreset('steps-partial', { id: 'acme-partial', actions: [spec] });
+ registerPreset(acme);
+ REGISTERED.push('acme-partial');
+ const receipt = (await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.twostep' },
+ { preset: 'acme-partial' },
+ )) as { status: string; failedStep?: { index: number } };
+ expect(receipt.status).toBe('partial');
+ expect(receipt.failedStep?.index).toBe(1);
+ });
+
+ test('partial-template text forms are the cross-language JSON forms', async () => {
+ const calls = stepsBase2('steps-parity', [{ status: 'ok' }]);
+ const acme = extendPreset('steps-parity', { id: 'acme-parity', actions: [parity] });
+ registerPreset(acme);
+ REGISTERED.push('acme-parity');
+ await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.parity', flag: true, items: [1, 2], obj: { a: 1 } },
+ { preset: 'acme-parity' },
+ );
+ expect(calls[0]!.texts).toEqual(['flag=true items=[1,2] obj={"a":1}']);
+ });
+
+ test('whole-string templates for absent args are dropped from arrays', async () => {
+ const calls = stepsBase2('steps-arrmiss', [{ status: 'ok' }]);
+ const acme = extendPreset('steps-arrmiss', { id: 'acme-arrmiss', actions: [arrmiss] });
+ registerPreset(acme);
+ REGISTERED.push('acme-arrmiss');
+ await dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'acme.arrmiss', present: 'AAA' },
+ { preset: 'acme-arrmiss' },
+ );
+ expect(calls[0]!.texts).toEqual(['AAA']);
+ });
+
+ test('a raw spec with empty steps is rejected at extend time', () => {
+ const raw = {
+ name: 'acme.empty',
+ description: 'd',
+ inputSchema: { type: 'object', properties: {} },
+ steps: [],
+ } as never;
+ expect(() => extendPreset('core', { id: 'acme-empty', actions: [raw] })).toThrow(SuperDocCliError);
+ });
+});
+
+describe('dispatch โ run tier (native)', () => {
+ function handleWithRevisions(revisions: string[]) {
+ let i = 0;
+ return {
+ info: async () => ({ revision: revisions[Math.min(i++, revisions.length - 1)] }),
+ } as unknown as BoundDocApi;
+ }
+
+ test('success receipt carries pre/post revision and the result', async () => {
+ const native = defineAction({
+ name: 'acme.native',
+ description: 'd',
+ input: { type: 'object', properties: { x: { type: 'number', default: 7 } } },
+ run: async (_doc, args) => ({ got: args.x }),
+ });
+ const acme = extendPreset('core', { id: 'acme-native', actions: [native] });
+ registerPreset(acme);
+ REGISTERED.push('acme-native');
+ const receipt = (await dispatchSuperDocTool(
+ handleWithRevisions(['0', '1']),
+ 'superdoc_perform_action',
+ { action: 'acme.native' },
+ { preset: 'acme-native' },
+ )) as Record;
+ expect(receipt.status).toBe('succeeded');
+ expect(receipt.result).toEqual({ got: 7 });
+ expect(receipt.preRevision).toBe('0');
+ expect(receipt.postRevision).toBe('1');
+ });
+
+ test('failure after mutation reports partialMutation + revert recovery', async () => {
+ const native = defineAction({
+ name: 'acme.native-fail',
+ description: 'd',
+ run: async () => {
+ throw new Error('boom between ops');
+ },
+ });
+ const acme = extendPreset('core', { id: 'acme-native-fail', actions: [native] });
+ registerPreset(acme);
+ REGISTERED.push('acme-native-fail');
+ const receipt = (await dispatchSuperDocTool(
+ handleWithRevisions(['3', '4']),
+ 'superdoc_perform_action',
+ { action: 'acme.native-fail' },
+ { preset: 'acme-native-fail' },
+ )) as Record;
+ expect(receipt.status).toBe('failed');
+ expect(receipt.partialMutation).toBe(true);
+ expect((receipt.recovery as { kind: string }).kind).toBe('revert');
+ });
+
+ test('failure without mutation reports retry recovery', async () => {
+ const native = defineAction({
+ name: 'acme.native-clean-fail',
+ description: 'd',
+ run: async () => {
+ throw new Error('failed before touching the doc');
+ },
+ });
+ const acme = extendPreset('core', { id: 'acme-native-cf', actions: [native] });
+ registerPreset(acme);
+ REGISTERED.push('acme-native-cf');
+ const receipt = (await dispatchSuperDocTool(
+ handleWithRevisions(['5', '5']),
+ 'superdoc_perform_action',
+ { action: 'acme.native-clean-fail' },
+ { preset: 'acme-native-cf' },
+ )) as Record;
+ expect(receipt.status).toBe('failed');
+ expect(receipt.partialMutation).toBe(false);
+ expect((receipt.recovery as { kind: string }).kind).toBe('retry');
+ });
+});
+
+describe('composePreset', () => {
+ test('custom-only preset (includeCoreActions: []) still advertises perform_action', async () => {
+ const composed = composePreset({
+ id: 'composed-custom-only',
+ baseId: 'core',
+ includeCoreActions: [],
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-custom-only');
+ const { tools } = await chooseTools({ provider: 'openai', preset: 'composed-custom-only' });
+ const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ expect(perform).toBeDefined(); // synthesized โ the base dropped it
+ const names = actionProp(perform)?.enum ?? [];
+ for (const r of footnoteActions) expect(names).toContain(r.name);
+ expect(names).not.toContain('insert_paragraphs');
+ });
+
+ test('includeCoreActions + excludeActions(custom) does not leak the excluded name into the enum', async () => {
+ const composed = composePreset({
+ id: 'composed-leak',
+ baseId: 'core',
+ includeCoreActions: ['insert_paragraphs'],
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-leak');
+ const { tools } = await chooseTools({
+ provider: 'generic',
+ preset: 'composed-leak',
+ excludeActions: ['footnotes.add'],
+ });
+ const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ const names = actionProp(perform)?.enum ?? [];
+ expect(names).not.toContain('footnotes.add'); // must not leak back via the allowlist rebuild
+ expect(names).toContain('footnotes.list');
+ expect(names).toContain('insert_paragraphs');
+ // The DESCRIPTION narrows with the allowlist too โ the base rebuilds it
+ // from the derived exclusion, not a hand-rolled filter.
+ const description = (perform as { description?: string }).description ?? '';
+ expect(description).not.toContain('create_table');
+ });
+
+ test('getToolCatalog narrows the composed perform_action enum, args, AND description with the allowlist', async () => {
+ const composed = composePreset({
+ id: 'composed-cat',
+ baseId: 'core',
+ includeCoreActions: ['insert_paragraphs'],
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-cat');
+ const catalog = await getToolCatalog('composed-cat');
+ const perform = catalog.tools.find((t) => t.toolName === 'superdoc_perform_action');
+ const schema = perform?.inputSchema as { properties?: Record } | undefined;
+ const names = schema?.properties?.action?.enum ?? [];
+ expect(names).toContain('insert_paragraphs');
+ expect(names).not.toContain('create_table'); // outside the allowlist โ must not appear in the catalog
+ // The row narrows BEYOND the enum: create_table-only args are gone, so a
+ // catalog-driven UI/validator can't offer inputs for an action the preset
+ // refuses (the coherence gap this test guards against).
+ const props = Object.keys(schema?.properties ?? {});
+ expect(props).not.toContain('rows');
+ expect(props).not.toContain('columns');
+ expect(props).toContain('text'); // insert_paragraphs' own arg survives
+ expect(perform?.description ?? '').not.toContain('create_table'); // description narrows too
+ // custom actions appear as their own catalog rows (the catalog keeps custom
+ // as separate rows rather than merging them into perform_action's enum the
+ // way getTools does โ so the built-in row here carries built-ins only).
+ expect(catalog.tools.some((t) => t.toolName === 'footnotes.add')).toBe(true);
+ });
+
+ test('getToolCatalog perform_action row matches getTools when there are no custom actions', async () => {
+ const composed = composePreset({
+ id: 'composed-cat-nocustom',
+ baseId: 'core',
+ includeCoreActions: ['insert_paragraphs'],
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-cat-nocustom');
+ const catalog = await getToolCatalog('composed-cat-nocustom');
+ const row = catalog.tools.find((t) => t.toolName === 'superdoc_perform_action');
+ const rowProps = Object.keys((row?.inputSchema as { properties?: Record })?.properties ?? {});
+ // With no custom actions the two representations coincide, so the row must
+ // equal the advertised tool exactly โ one narrowing, no drift.
+ const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-cat-nocustom' });
+ const advertised = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ expect(
+ (row?.inputSchema as { properties?: { action?: { enum?: string[] } } })?.properties?.action?.enum ?? [],
+ ).toEqual(actionProp(advertised)?.enum ?? []);
+ expect(rowProps.sort()).toEqual(Object.keys(schemaOf(advertised)?.properties ?? {}).sort());
+ });
+
+ test('getToolCatalog drops perform_action when the allowlist excludes every built-in', async () => {
+ const empty = composePreset({
+ id: 'composed-cat-empty',
+ baseId: 'core',
+ includeCoreActions: [],
+ });
+ registerPreset(empty);
+ REGISTERED.push('composed-cat-empty');
+ const emptyCatalog = await getToolCatalog('composed-cat-empty');
+ expect(emptyCatalog.tools.some((t) => t.toolName === 'superdoc_perform_action')).toBe(false);
+
+ const customOnly = composePreset({
+ id: 'composed-cat-custom-only',
+ baseId: 'core',
+ includeCoreActions: [],
+ actions: footnoteActions,
+ });
+ registerPreset(customOnly);
+ REGISTERED.push('composed-cat-custom-only');
+ const customOnlyCatalog = await getToolCatalog('composed-cat-custom-only');
+ expect(customOnlyCatalog.tools.some((t) => t.toolName === 'superdoc_perform_action')).toBe(false);
+ for (const action of footnoteActions) {
+ expect(customOnlyCatalog.tools.some((t) => t.toolName === action.name)).toBe(true);
+ }
+ });
+
+ test('includeCoreActions is enforced at DISPATCH, not only in the enum', async () => {
+ const composed = composePreset({
+ id: 'composed-allowlist',
+ baseId: 'core',
+ includeCoreActions: ['insert_paragraphs'],
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-allowlist');
+ await expect(
+ dispatchSuperDocTool(
+ {} as BoundDocApi,
+ 'superdoc_perform_action',
+ { action: 'create_table', rows: 1, columns: 1 },
+ { preset: 'composed-allowlist' },
+ ),
+ ).rejects.toMatchObject({
+ code: 'INVALID_ARGUMENT',
+ details: { excluded: true, excludedBy: 'includeCoreActions' },
+ });
+ });
+
+ test('excludeActions with a CUSTOM name neither throws nor advertises it', async () => {
+ const composed = composePreset({ id: 'composed-excl', baseId: 'core', actions: footnoteActions });
+ registerPreset(composed);
+ REGISTERED.push('composed-excl');
+ const excludeActions = ['footnotes.add', 'create_table'];
+ const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-excl', excludeActions });
+ const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? [];
+ expect(names).not.toContain('footnotes.add');
+ expect(names).not.toContain('create_table');
+ expect(names).toContain('footnotes.list');
+ const prompt = await getSystemPrompt('composed-excl', { excludeActions });
+ expect(prompt).not.toContain('- footnotes.add โ');
+ });
+
+ test('drops superdoc_execute_code from advertised tools but keeps it dispatchable', async () => {
+ const composed = composePreset({
+ id: 'composed-1',
+ baseId: 'core',
+ includeExecuteCode: false,
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-1');
+ const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-1' });
+ expect(tools.map(toolName)).not.toContain('superdoc_execute_code');
+ // custom actions still merged into superdoc_perform_action
+ const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? [];
+ expect(names).toContain('footnotes.add');
+ });
+
+ test('includeCoreActions filters the superdoc_perform_action enum to the subset โช custom names', async () => {
+ const composed = composePreset({
+ id: 'composed-2',
+ baseId: 'core',
+ includeCoreActions: ['insert_paragraphs', 'replace_text'],
+ actions: footnoteActions,
+ });
+ registerPreset(composed);
+ REGISTERED.push('composed-2');
+ const { tools } = await chooseTools({ provider: 'generic', preset: 'composed-2' });
+ const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? [];
+ expect(names).toContain('insert_paragraphs');
+ expect(names).toContain('replace_text');
+ expect(names).toContain('footnotes.add');
+ expect(names).not.toContain('create_table');
+ });
+});
+
+describe('createAgentToolkit โ one-call custom actions', () => {
+ test('actions build the surface directly โ no registerPreset, no preset id to manage', async () => {
+ const stamp = defineAction({
+ name: 'superdoc.demo_stamp',
+ description: 'Insert a demo banner.',
+ input: { type: 'object', properties: { label: { type: 'string' } } },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['{{label}}'] } }],
+ });
+ const { tools, systemPrompt, dispatch, meta } = await createAgentToolkit({
+ provider: 'openai',
+ actions: [stamp],
+ });
+ const perform = tools.find((t) => toolName(t) === 'superdoc_perform_action');
+ expect(actionProp(perform)?.enum ?? []).toContain('superdoc.demo_stamp'); // custom action advertised
+ expect(systemPrompt).toContain('superdoc.demo_stamp'); // prompt narrows WITH it
+ expect(typeof dispatch).toBe('function');
+ expect(meta.preset).toBe('custom_superdoc_preset');
+ expect(listPresets().includes('custom_superdoc_preset')).toBe(false); // ephemeral โ no global leak
+ });
+
+ test('includeCoreActions keeps only the named built-ins alongside customs', async () => {
+ const stamp = defineAction({
+ name: 'superdoc.demo_stamp',
+ description: 'Insert a demo banner.',
+ input: { type: 'object', properties: {} },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }],
+ });
+ const { tools } = await createAgentToolkit({
+ provider: 'generic',
+ actions: [stamp],
+ includeCoreActions: ['insert_paragraphs'],
+ });
+ const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? [];
+ expect(names).toContain('insert_paragraphs');
+ expect(names).toContain('superdoc.demo_stamp');
+ expect(names).not.toContain('create_table');
+ });
+});
+
+describe('defineAction / merge โ schema guards', () => {
+ test('two actions declaring the same arg with incompatible types are rejected', async () => {
+ const a = defineAction({
+ name: 'x.a',
+ description: 'a',
+ input: { type: 'object', properties: { foo: { type: 'string' } } },
+ run: () => null,
+ });
+ const b = defineAction({
+ name: 'x.b',
+ description: 'b',
+ input: { type: 'object', properties: { foo: { type: 'number' } } },
+ run: () => null,
+ });
+ await expect(createAgentToolkit({ provider: 'openai', actions: [a, b] })).rejects.toThrow(
+ /argument "foo".*conflicts/,
+ );
+ });
+
+ test('a shared arg name with a compatible type + extra DESCRIPTION only is allowed', async () => {
+ // Reusing a built-in arg name (e.g. anchorText) with your own description
+ // is fine โ only documentation differs, so it is NOT a conflict.
+ const a = defineAction({
+ name: 'x.a',
+ description: 'a',
+ input: { type: 'object', properties: { foo: { type: 'string' } } },
+ run: () => null,
+ });
+ const b = defineAction({
+ name: 'x.b',
+ description: 'b',
+ input: { type: 'object', properties: { foo: { type: 'string', description: 'documented' } } },
+ run: () => null,
+ });
+ const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] });
+ expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.b');
+ });
+
+ test('a shared arg name whose schemas differ beyond description (one-sided enum) is rejected', async () => {
+ const a = defineAction({
+ name: 'x.a',
+ description: 'a',
+ input: { type: 'object', properties: { mode: { type: 'string' } } },
+ run: () => null,
+ });
+ const b = defineAction({
+ name: 'x.b',
+ description: 'b',
+ input: { type: 'object', properties: { mode: { type: 'string', enum: ['fast', 'slow'] } } },
+ run: () => null,
+ });
+ await expect(createAgentToolkit({ provider: 'generic', actions: [a, b] })).rejects.toThrow(
+ /argument "mode".*conflicts/,
+ );
+ });
+
+ test('the same arg with an IDENTICAL schema across actions is allowed', async () => {
+ const a = defineAction({
+ name: 'x.a',
+ description: 'a',
+ input: { type: 'object', properties: { foo: { type: 'string' } } },
+ run: () => null,
+ });
+ const b = defineAction({
+ name: 'x.b',
+ description: 'b',
+ input: { type: 'object', properties: { foo: { type: 'string' } } },
+ run: () => null,
+ });
+ const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] });
+ expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.a');
+ });
+
+ test('the same arg with a REORDERED but identical schema is allowed (order-insensitive compare)', async () => {
+ const a = defineAction({
+ name: 'x.a',
+ description: 'a',
+ input: { type: 'object', properties: { foo: { type: 'string', description: 'the foo' } } },
+ run: () => null,
+ });
+ const b = defineAction({
+ name: 'x.b',
+ description: 'b',
+ input: { type: 'object', properties: { foo: { description: 'the foo', type: 'string' } } },
+ run: () => null,
+ });
+ const { tools } = await createAgentToolkit({ provider: 'generic', actions: [a, b] });
+ expect(actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? []).toContain('x.b');
+ });
+
+ test('a Zod-like schema is rejected with a clear error (convert to JSON Schema)', () => {
+ const zodish = { _def: { typeName: 'ZodObject' }, parse: () => ({}) };
+ expect(() => defineAction({ name: 'x.zod', description: 'z', input: zodish, run: () => null } as never)).toThrow(
+ /JSON Schema/,
+ );
+ });
+
+ test('preset surface is immutable โ mutating the caller actions array after build has no effect', async () => {
+ const mk = (name: string) =>
+ defineAction({
+ name,
+ description: 'd',
+ input: { type: 'object', properties: {} },
+ steps: [{ action: 'insert_paragraphs', args: { texts: ['x'] } }],
+ });
+ const arr = [mk('x.snapshotted')];
+ const preset = extendPreset('core', { id: 'snap-preset', actions: arr });
+ registerPreset(preset);
+ REGISTERED.push('snap-preset');
+
+ // Caller mutates its array AFTER the preset was built โ must NOT leak in.
+ arr.length = 0;
+ arr.push(mk('x.injected'));
+
+ const { tools } = await chooseTools({ provider: 'generic', preset: 'snap-preset' });
+ const names = actionProp(tools.find((t) => toolName(t) === 'superdoc_perform_action'))?.enum ?? [];
+ expect(names).toContain('x.snapshotted'); // original still advertised
+ expect(names).not.toContain('x.injected'); // post-build push did not leak into the surface
+ });
+});
diff --git a/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts b/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts
new file mode 100644
index 0000000000..479ffa0258
--- /dev/null
+++ b/packages/sdk/langs/node/src/__tests__/fixtures/footnotes.ts
@@ -0,0 +1,137 @@
+/**
+ * Footnote actions โ the shared test fixture for custom actions.
+ *
+ * Footnotes are NOT built-in core actions; these define five namespaced
+ * `run`-tier custom actions (`footnotes.add` / `.list` / `.edit` / `.remove` /
+ * `.renumber`) over the typed `doc.footnotes.*` Document API. They run in the
+ * caller's process against the session-bound handle โ the same tier a customer
+ * reaches for when the built-in actions don't cover their domain.
+ *
+ * This is a test fixture only: it is not exported from the package and lives
+ * under __tests__ so it never ships in dist. Both the unit tests (against a
+ * fake base) and the e2e test (against the real CLI host) import it.
+ *
+ * Shapes (from packages/document-api/src/footnotes/footnotes.types.ts):
+ * - insert `at` is a TextTarget: { kind:'text', segments:[{ blockId, range:{start,end} }] }
+ * - update/remove target a FootnoteAddress: { kind:'entity', entityType:'footnote', noteId }
+ * - configure takes { type, scope:{kind:'document'}, numbering:{ format?, start? } }
+ */
+
+import { defineAction, type ActionSpec } from '../../actions/define.js';
+
+type FootnotesApi = {
+ footnotes: {
+ insert: (input: Record) => Promise;
+ list: (input: Record) => Promise;
+ update: (input: Record) => Promise;
+ remove: (input: Record) => Promise;
+ configure: (input: Record) => Promise;
+ };
+};
+
+export const footnoteAdd: ActionSpec = defineAction({
+ name: 'footnotes.add',
+ description:
+ "Insert a footnote (or endnote) at a text target. args: { at: TextTarget {kind:'text',segments:[{blockId,range:{start,end}}]}, content: string, type?: 'footnote'|'endnote' }.",
+ input: {
+ type: 'object',
+ additionalProperties: false,
+ required: ['at', 'content'],
+ properties: {
+ at: { type: 'object', description: "TextTarget: {kind:'text',segments:[{blockId,range:{start,end}}]}" },
+ content: { type: 'string' },
+ type: { type: 'string', enum: ['footnote', 'endnote'] },
+ },
+ },
+ run: (doc, args) =>
+ (doc as FootnotesApi).footnotes.insert({
+ at: args.at,
+ type: (args.type as string) || 'footnote',
+ content: args.content,
+ }),
+});
+
+export const footnoteList: ActionSpec = defineAction({
+ name: 'footnotes.list',
+ description: "List footnotes (or endnotes). args: { type?: 'footnote'|'endnote' }.",
+ input: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ type: { type: 'string', enum: ['footnote', 'endnote'] },
+ },
+ },
+ run: (doc, args) => (doc as FootnotesApi).footnotes.list(args.type ? { type: args.type } : {}),
+});
+
+export const footnoteEdit: ActionSpec = defineAction({
+ name: 'footnotes.edit',
+ description: "Edit a footnote's content by noteId. args: { noteId: string, content: string }.",
+ input: {
+ type: 'object',
+ additionalProperties: false,
+ required: ['noteId', 'content'],
+ properties: {
+ noteId: { type: 'string' },
+ content: { type: 'string' },
+ },
+ },
+ run: (doc, args) =>
+ (doc as FootnotesApi).footnotes.update({
+ target: { kind: 'entity', entityType: 'footnote', noteId: args.noteId },
+ patch: { content: args.content },
+ }),
+});
+
+export const footnoteRemove: ActionSpec = defineAction({
+ name: 'footnotes.remove',
+ description: 'Remove a footnote by noteId. args: { noteId: string }.',
+ input: {
+ type: 'object',
+ additionalProperties: false,
+ required: ['noteId'],
+ properties: {
+ noteId: { type: 'string' },
+ },
+ },
+ run: (doc, args) =>
+ (doc as FootnotesApi).footnotes.remove({
+ target: { kind: 'entity', entityType: 'footnote', noteId: args.noteId },
+ }),
+});
+
+export const footnoteRenumber: ActionSpec = defineAction({
+ name: 'footnotes.renumber',
+ description:
+ "Reconfigure footnote numbering for the whole document. args: { type?: 'footnote'|'endnote', format?: string, start?: number }.",
+ input: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ type: { type: 'string', enum: ['footnote', 'endnote'] },
+ format: {
+ type: 'string',
+ enum: ['decimal', 'lowerRoman', 'upperRoman', 'lowerLetter', 'upperLetter', 'symbol'],
+ },
+ start: { type: 'number' },
+ },
+ },
+ run: (doc, args) =>
+ (doc as FootnotesApi).footnotes.configure({
+ type: (args.type as string) || 'footnote',
+ scope: { kind: 'document' },
+ numbering: {
+ ...(args.format ? { format: args.format } : {}),
+ ...(args.start != null ? { start: args.start } : {}),
+ },
+ }),
+});
+
+/** All five footnote actions, in a stable order. */
+export const footnoteActions: readonly ActionSpec[] = [
+ footnoteAdd,
+ footnoteList,
+ footnoteEdit,
+ footnoteRemove,
+ footnoteRenumber,
+];
diff --git a/packages/sdk/langs/node/src/actions/define.ts b/packages/sdk/langs/node/src/actions/define.ts
new file mode 100644
index 0000000000..71735c13b0
--- /dev/null
+++ b/packages/sdk/langs/node/src/actions/define.ts
@@ -0,0 +1,1115 @@
+/**
+ * Customer-extensible **custom actions** for the SuperDoc LLM-tools SDK. The
+ * canonical ActionSpec has exactly ONE execution tier:
+ *
+ * - `steps` โ declarative composition of built-in core actions with
+ * {{arg}} templating; dispatches through the base preset and
+ * inherits its target resolution, receipts, and verification.
+ * - `run` โ a native function executed in the CALLER'S process against
+ * the typed session-bound doc handle, with synthesized
+ * truth-telling receipts (pre/post revision, partialMutation).
+ *
+ * NOTE: a third, IN-HOST tier โ a JS function-expression run inside the
+ * document host via `superdoc_execute_code` โ is intentionally not part of the
+ * kit yet. It lands together with the code-act execution path (and its safety
+ * envelope); until then the kit exposes only `steps` and `run`.
+ *
+ * `extendPreset`/`composePreset` merge custom actions into the
+ * superdoc_perform_action enum, tool description, system prompt, and dispatch
+ * COHERENTLY โ including excludeActions, which may name built-in actions
+ * (forwarded to the base) or custom ones (handled by the wrapper). No CLI
+ * host changes are required by either tier.
+ *
+ * Cross-runtime contract: templating semantics, input-schema defaults, and
+ * receipt shapes are identical to the Python mirror
+ * (`langs/python/superdoc/presets/custom.py`) for any JSON-serializable tool
+ * input. (NaN/Infinity/lone-surrogates are out of scope: they cannot appear in
+ * a JSON tool call.)
+ *
+ * @module
+ */
+
+import type { BoundDocApi } from '../generated/client.js';
+import type { InvokeOptions } from '../runtime/process.js';
+import { SuperDocCliError } from '../runtime/errors.js';
+import {
+ getPreset,
+ type GetSystemPromptOptions,
+ type GetToolsOptions,
+ type GetToolsResult,
+ type PresetDescriptor,
+ type ToolCatalog,
+ type ToolCatalogEntry,
+ type ToolProvider,
+} from '../presets.js';
+import { ACTION_NAMES_LIST } from '../agent/actions.js';
+import { AGENT_TOOL_NAMES, buildPerformActionDefinition } from '../agent/catalog.js';
+
+// ---------------------------------------------------------------------------
+// ActionSpec โ the shared contract (identical fields in Node & Python)
+// ---------------------------------------------------------------------------
+
+/** Minimal JSON Schema object describing a action's flat args. */
+export type JSONSchemaObject = {
+ type: 'object';
+ properties: Record;
+ required?: string[];
+ additionalProperties?: boolean;
+ [key: string]: unknown;
+};
+
+/**
+ * One step of a declarative (`steps`-tier) custom action: an existing built-in
+ * core action plus its args. String values may reference the custom action's
+ * own args with `{{name}}` templates โ a whole-string `"{{x}}"` substitutes the
+ * raw value (preserving arrays/objects/numbers), a partial `"...{{x}}..."`
+ * interpolates as text.
+ */
+export type ActionStep = {
+ action: string;
+ args?: Record;
+};
+
+/**
+ * The shared, language-neutral custom-action contract โ the canonical type.
+ *
+ * - `name` โ namespaced "." (e.g. "footnotes.add"). MUST NOT collide
+ * with a built-in core action name.
+ * - `description` โ shown to the model.
+ * - `inputSchema` โ JSON Schema for the FLAT args (NOT the `action`
+ * discriminator key). `properties.*.default` values are applied before
+ * execution on every tier.
+ *
+ * Exactly ONE execution tier is set:
+ *
+ * - `steps` โ DECLARATIVE (recommended): a sequence of built-in core actions
+ * with `{{arg}}` templating. Dispatches through the base preset, so it
+ * inherits target resolution, placement handling, receipts, and
+ * verification. Pure data โ serializable, cross-language by construction.
+ * - `run` โ NATIVE escape hatch: an async function executed in the caller's
+ * process against the typed session-bound doc handle. Receipts are
+ * synthesized (pre/post revision, partialMutation, recovery).
+ */
+export interface ActionSpec {
+ name: string;
+ description: string;
+ inputSchema: JSONSchemaObject;
+ steps?: readonly ActionStep[];
+ run?: (doc: BoundDocApi, args: Record) => unknown;
+}
+
+/** Which execution tier a spec uses. */
+export function executionKindOf(spec: ActionSpec): 'steps' | 'run' {
+ return Array.isArray(spec.steps) ? 'steps' : 'run';
+}
+
+const BUILTIN_ACTION_NAMES: ReadonlySet = new Set(ACTION_NAMES_LIST);
+
+// ---------------------------------------------------------------------------
+// defineAction โ author a ActionSpec
+// ---------------------------------------------------------------------------
+
+type DefineActionInputBase = {
+ name: string;
+ description: string;
+ /**
+ * JSON Schema object describing the action's flat args. Pass a plain JSON
+ * Schema (`{ type: 'object', properties: {...} }`). Zod (and other schema
+ * libraries) are NOT accepted directly โ convert first, e.g. with
+ * `zod-to-json-schema` โ otherwise `defineAction` throws a clear error rather
+ * than silently dropping your types.
+ */
+ input?: JSONSchemaObject | Record;
+};
+
+type DefineActionWithSteps = DefineActionInputBase & {
+ /** Declarative tier: built-in core actions with {{arg}} templating. */
+ steps: readonly ActionStep[];
+ run?: never;
+};
+
+type DefineActionWithRun = DefineActionInputBase & {
+ /**
+ * Native tier: runs IN THE CALLER'S PROCESS against the typed session-bound
+ * doc handle (async `doc.*` client API).
+ */
+ run: (doc: BoundDocApi, args: Record) => unknown;
+ steps?: never;
+};
+
+export type DefineActionInput = DefineActionWithSteps | DefineActionWithRun;
+
+function isJsonSchemaObject(value: unknown): value is JSONSchemaObject {
+ return (
+ value != null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ (value as Record).type === 'object'
+ );
+}
+
+/**
+ * Normalize the `input` field to a JSON Schema object. We take NO schema-library
+ * dependency: a Zod (or Zod-like) schema is rejected with a clear, actionable
+ * error instead of being silently accepted with its types dropped โ the caller
+ * converts it first (e.g. `zod-to-json-schema`). A plain JSON Schema object is
+ * the primary path; a bare properties bag is wrapped for convenience.
+ */
+function coerceInputSchema(input: DefineActionInput['input']): JSONSchemaObject {
+ if (input == null) {
+ return { type: 'object', properties: {}, additionalProperties: true };
+ }
+ // Zod / Zod-like schemas expose `_def` (and usually `.parse`/`.safeParse`).
+ // Reject clearly rather than half-supporting them (dropping constraints).
+ // Checked BEFORE isJsonSchemaObject so a library schema can never slip through.
+ const duck = input as { _def?: unknown; parse?: unknown; safeParse?: unknown };
+ if (duck._def != null || typeof duck.parse === 'function' || typeof duck.safeParse === 'function') {
+ throw new SuperDocCliError(
+ 'defineAction `input` must be a JSON Schema object, not a Zod (or other library) schema. ' +
+ 'Convert it first, e.g. `import { zodToJsonSchema } from "zod-to-json-schema"; ' +
+ 'defineAction({ input: zodToJsonSchema(mySchema), ... })`.',
+ { code: 'INVALID_ARGUMENT' },
+ );
+ }
+ if (isJsonSchemaObject(input)) {
+ // A valid object schema may legally omit `properties` (an open schema like
+ // `{ type: 'object', additionalProperties: true }`). The declared type makes
+ // `properties` required and every consumer (and the Python mirror) assumes
+ // it exists, so normalize a missing/non-object bag to {}.
+ return isRecord((input as { properties?: unknown }).properties) ? input : { ...input, properties: {} };
+ }
+ // A bare properties bag โ wrap it.
+ return { type: 'object', properties: input as Record, additionalProperties: true };
+}
+
+/**
+ * Author a {@link ActionSpec}. Pass exactly one execution tier: `steps`
+ * (declarative โ recommended) or `run` (native function in your process).
+ */
+export function defineAction(input: DefineActionInput): ActionSpec {
+ if (typeof input.name !== 'string' || input.name.length === 0) {
+ throw new SuperDocCliError('defineAction requires a non-empty `name`.', {
+ code: 'INVALID_ARGUMENT',
+ details: { input: 'name' },
+ });
+ }
+ if (typeof input.description !== 'string') {
+ throw new SuperDocCliError(`defineAction "${input.name}" requires a string \`description\`.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { name: input.name },
+ });
+ }
+ const tiers: Array<'steps' | 'run'> = [];
+ if (Array.isArray(input.steps)) tiers.push('steps');
+ if (typeof input.run === 'function') tiers.push('run');
+ if (tiers.length !== 1) {
+ throw new SuperDocCliError(
+ `defineAction "${input.name}" requires exactly one of \`steps\` or \`run\` (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`,
+ { code: 'INVALID_ARGUMENT', details: { name: input.name, tiers } },
+ );
+ }
+ const spec: ActionSpec = {
+ name: input.name,
+ description: input.description,
+ inputSchema: coerceInputSchema(input.input),
+ };
+ if (tiers[0] === 'steps') {
+ const steps = input.steps as readonly ActionStep[];
+ if (steps.length === 0) {
+ throw new SuperDocCliError(`defineAction "${input.name}": \`steps\` must be a non-empty array.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { name: input.name },
+ });
+ }
+ steps.forEach((step, index) => {
+ if (step == null || typeof step.action !== 'string' || step.action.length === 0) {
+ throw new SuperDocCliError(
+ `defineAction "${input.name}": steps[${index}] needs a non-empty \`action\` string.`,
+ { code: 'INVALID_ARGUMENT', details: { name: input.name, index } },
+ );
+ }
+ if (!BUILTIN_ACTION_NAMES.has(step.action)) {
+ throw new SuperDocCliError(
+ `defineAction "${input.name}": steps[${index}].action "${step.action}" is not a built-in core action. Steps compose built-in actions only; use the \`run\` tier for anything else.`,
+ { code: 'INVALID_ARGUMENT', details: { name: input.name, index, action: step.action } },
+ );
+ }
+ if (step.args != null && !isRecord(step.args)) {
+ throw new SuperDocCliError(`defineAction "${input.name}": steps[${index}].args must be an object.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { name: input.name, index },
+ });
+ }
+ });
+ spec.steps = steps.map((step) => ({ action: step.action, args: { ...(step.args ?? {}) } }));
+ } else {
+ spec.run = (input as DefineActionWithRun).run;
+ }
+ return spec;
+}
+
+// ---------------------------------------------------------------------------
+// Codegen โ identical to the Python mirror for JSON-serializable tool inputs
+// (the only values a tool call can carry; NaN/Infinity/lone-surrogates are out
+// of scope and serialize differently across Node and Python).
+
+// ---------------------------------------------------------------------------
+// Collision / duplicate validation
+// ---------------------------------------------------------------------------
+
+/**
+ * Provider tool-name rule. OpenAI/Anthropic require tool names to match
+ * `^[A-Za-z0-9_-]{1,64}$` โ dots are invalid. This only matters in STANDALONE
+ * mode, where the action name becomes a tool name; in MERGED mode the name is
+ * an enum VALUE (dots are fine).
+ */
+const PROVIDER_SAFE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
+
+/** Tool names of the agent surface itself โ a custom action must never shadow one. */
+const RESERVED_TOOL_NAMES: ReadonlySet = new Set(AGENT_TOOL_NAMES);
+
+/**
+ * Shared by extendPreset and composePreset: excludeActions may name BUILT-IN
+ * actions (forwarded to the base, which validates them) or CUSTOM actions
+ * (handled by the wrapper โ the base would reject names it doesn't know).
+ * Both halves narrow tools, prompt, and dispatch together, preserving the
+ * kit's coherence guarantee.
+ */
+function splitCustomExclusions(
+ byName: ReadonlyMap,
+ list: readonly string[] | undefined,
+): { customExcluded: Set; builtinExcluded: readonly string[] | undefined } {
+ if (!list || list.length === 0) return { customExcluded: new Set(), builtinExcluded: undefined };
+ const customExcluded = new Set();
+ const builtin: string[] = [];
+ for (const name of list) {
+ if (byName.has(name)) customExcluded.add(name);
+ else builtin.push(name);
+ }
+ return { customExcluded, builtinExcluded: builtin.length > 0 ? builtin : undefined };
+}
+
+/** Defense-in-depth refusal shared by both wrappers' dispatchers. */
+function throwExcludedAction(toolName: string, actionName: string, extra: Record = {}): never {
+ throw new SuperDocCliError(`Action ${actionName} is excluded by configuration.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { toolName, action: actionName, excluded: true, ...extra },
+ });
+}
+
+/**
+ * Drop surface `excludeActions` from invoke options before INTERNAL step
+ * dispatch. Those exclusions govern what the MODEL may call directly (the
+ * top-level dispatch already enforced them); a steps-tier custom action is an
+ * authored composition whose steps must run even when a built-in they compose
+ * is hidden from the model. Everything else in invokeOptions passes through.
+ */
+function stripSurfaceExclusions(invokeOptions?: InvokeOptions): InvokeOptions | undefined {
+ if (!invokeOptions || !('excludeActions' in (invokeOptions as Record))) return invokeOptions;
+ const { excludeActions: _dropped, ...rest } = invokeOptions as InvokeOptions & { excludeActions?: unknown };
+ return rest as InvokeOptions;
+}
+
+function assertActionsValid(actions: readonly ActionSpec[], presetId: string, standalone = false): void {
+ const seen = new Set();
+ for (const action of actions) {
+ // Raw spec objects can bypass defineAction โ re-validate the tier shape
+ // here so a hand-rolled {steps: []} can't fabricate succeeded receipts.
+ const tiers = [
+ Array.isArray(action.steps) ? 'steps' : null,
+ typeof action.run === 'function' ? 'run' : null,
+ ].filter((tier): tier is string => tier != null);
+ if (tiers.length !== 1) {
+ throw new SuperDocCliError(
+ `Custom action "${action.name}" must have exactly one of steps/run (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`,
+ { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, tiers } },
+ );
+ }
+ if (Array.isArray(action.steps)) {
+ if (action.steps.length === 0) {
+ throw new SuperDocCliError(`Custom action "${action.name}": steps must be non-empty.`, {
+ code: 'INVALID_ARGUMENT',
+ details: { presetId, name: action.name },
+ });
+ }
+ for (const [index, step] of action.steps.entries()) {
+ if (!step || typeof step.action !== 'string' || !BUILTIN_ACTION_NAMES.has(step.action)) {
+ throw new SuperDocCliError(
+ `Custom action "${action.name}": steps[${index}].action must be a built-in core action.`,
+ { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, index } },
+ );
+ }
+ }
+ }
+ if (BUILTIN_ACTION_NAMES.has(action.name)) {
+ throw new SuperDocCliError(
+ `Custom action "${action.name}" collides with a built-in core action name. Use a namespaced name like "superdoc.${action.name}".`,
+ { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } },
+ );
+ }
+ if (RESERVED_TOOL_NAMES.has(action.name)) {
+ throw new SuperDocCliError(
+ `Custom action "${action.name}" collides with a reserved tool name โ it would shadow the agent surface itself.`,
+ { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } },
+ );
+ }
+ if (seen.has(action.name)) {
+ throw new SuperDocCliError(`Duplicate custom action name "${action.name}" in preset "${presetId}".`, {
+ code: 'INVALID_ARGUMENT',
+ details: { presetId, name: action.name },
+ });
+ }
+ // In standalone mode the action name becomes a provider tool name, which
+ // OpenAI/Anthropic reject unless it matches ^[A-Za-z0-9_-]{1,64}$ (dotted
+ // namespaced names are only valid as merged enum VALUES).
+ if (standalone && !PROVIDER_SAFE_TOOL_NAME.test(action.name)) {
+ throw new SuperDocCliError(
+ `standalone action names must match ^[A-Za-z0-9_-]{1,64}$; "${action.name}" has invalid characters โ use merged mode for dotted names.`,
+ { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } },
+ );
+ }
+ seen.add(action.name);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// runCustomAction โ validate, codegen, dispatch via superdoc_execute_code, map receipt
+// ---------------------------------------------------------------------------
+
+function isRecord(value: unknown): value is Record {
+ return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** Kit-level args every custom action accepts without declaring them. */
+const IMPLICIT_ACTION_ARGS = new Set(['changeMode', 'rationale']);
+
+function validateAgainstSchema(action: ActionSpec, args: Record): void {
+ const required = Array.isArray(action.inputSchema.required) ? action.inputSchema.required : [];
+ const missing = required.filter((key) => args[key] == null);
+ if (missing.length > 0) {
+ throw new SuperDocCliError(`Missing required argument(s) for ${action.name}: ${missing.join(', ')}`, {
+ code: 'INVALID_ARGUMENT',
+ details: { action: action.name, missingKeys: missing },
+ });
+ }
+ const properties = isRecord(action.inputSchema.properties) ? action.inputSchema.properties : {};
+ if (action.inputSchema.additionalProperties === false) {
+ const unknown = Object.keys(args).filter((key) => !(key in properties) && !IMPLICIT_ACTION_ARGS.has(key));
+ if (unknown.length > 0) {
+ throw new SuperDocCliError(`Unknown argument(s) for ${action.name}: ${unknown.join(', ')}`, {
+ code: 'INVALID_ARGUMENT',
+ details: { action: action.name, unknownKeys: unknown, knownKeys: Object.keys(properties) },
+ });
+ }
+ }
+ for (const [key, prop] of Object.entries(properties)) {
+ const value = args[key];
+ if (value !== undefined && isRecord(prop) && Array.isArray(prop.enum) && !prop.enum.includes(value)) {
+ throw new SuperDocCliError(
+ `Invalid value for ${action.name}.${key}: ${JSON.stringify(value)} (allowed: ${prop.enum.map((entry) => JSON.stringify(entry)).join(', ')})`,
+ { code: 'INVALID_ARGUMENT', details: { action: action.name, key, allowed: prop.enum } },
+ );
+ }
+ }
+}
+
+/** Fill in `inputSchema.properties.*.default` values for absent args. */
+function applyInputDefaults(action: ActionSpec, args: Record): Record {
+ const out = { ...args };
+ for (const [key, prop] of Object.entries(action.inputSchema.properties ?? {})) {
+ if (out[key] === undefined && isRecord(prop) && 'default' in prop) out[key] = prop.default;
+ }
+ return out;
+}
+
+const WHOLE_TEMPLATE = /^\{\{(\w+)\}\}$/;
+
+/**
+ * Substitute `{{arg}}` templates in a step's args. A whole-string `"{{x}}"`
+ * yields the RAW value (arrays/objects/numbers survive); partial templates
+ * interpolate as text. Keys whose whole-string template resolves to undefined
+ * are dropped, so optional args don't inject `undefined` into step args.
+ */
+function substituteTemplates(node: unknown, vars: Record): unknown {
+ if (typeof node === 'string') {
+ const whole = WHOLE_TEMPLATE.exec(node);
+ if (whole) return vars[whole[1]!];
+ return node.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
+ const value = vars[name];
+ // Text interpolation must be byte-identical across Node and Python:
+ // strings verbatim, null/undefined โ '', everything else compact JSON
+ // (true/[1,2]/{"a":1} โ NOT String(), whose array/object/boolean forms
+ // differ from Python's str()).
+ if (value == null) return '';
+ return typeof value === 'string' ? value : JSON.stringify(value);
+ });
+ }
+ if (Array.isArray(node)) {
+ // Whole-string templates for ABSENT args are dropped from arrays too โ
+ // leaving undefined behind would serialize as null (and Python's sentinel
+ // would crash the transport).
+ return node.map((item) => substituteTemplates(item, vars)).filter((item) => item !== undefined);
+ }
+ if (isRecord(node)) {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(node)) {
+ const substituted = substituteTemplates(value, vars);
+ if (substituted !== undefined) out[key] = substituted;
+ }
+ return out;
+ }
+ return node;
+}
+
+type StepReceiptRow = {
+ step: number;
+ action: string;
+ status: unknown;
+ verificationPassed?: unknown;
+};
+
+/**
+ * `steps` tier โ dispatch each built-in step through the base preset and
+ * aggregate per-step receipts. Stops at the first failed step; the aggregate
+ * status is `partial` when earlier steps landed, `failed` otherwise.
+ */
+async function runStepsAction(
+ base: PresetDescriptor,
+ action: ActionSpec,
+ documentHandle: BoundDocApi,
+ args: Record,
+ invokeOptions?: InvokeOptions,
+): Promise> {
+ const vars = args; // defaults already applied by the router
+ const rows: StepReceiptRow[] = [];
+ // Steps are the author's curated composition, not model calls โ surface
+ // exclusions must not refuse a built-in the action deliberately composes.
+ const stepOptions = stripSurfaceExclusions(invokeOptions);
+ for (const [index, step] of (action.steps ?? []).entries()) {
+ let stepArgs = substituteTemplates(step.args ?? {}, vars) as Record;
+ // changeMode pass-through: a caller-level changeMode reaches every step
+ // that doesn't pin its own.
+ if (typeof vars.changeMode === 'string' && stepArgs.changeMode === undefined) {
+ stepArgs = { ...stepArgs, changeMode: vars.changeMode };
+ }
+ let receipt: Record;
+ try {
+ const dispatched = await base.dispatch(
+ documentHandle,
+ 'superdoc_perform_action',
+ { action: step.action, ...stepArgs },
+ stepOptions,
+ );
+ receipt = isRecord(dispatched) ? dispatched : { status: 'ok', result: dispatched };
+ } catch (error) {
+ // Validation errors THROW from dispatch; runtime failures come back as
+ // failed receipts. Normalize both into the per-step row.
+ const err = error as { code?: string; message?: string };
+ receipt = { status: 'failed', errors: [{ code: err.code ?? null, message: err.message ?? String(error) }] };
+ }
+ rows.push({
+ step: index,
+ action: step.action,
+ status: receipt.status,
+ ...(receipt.verificationPassed !== undefined ? { verificationPassed: receipt.verificationPassed } : {}),
+ });
+ if (receipt.status === 'failed') {
+ const anyLanded = rows.some((row) => row.status !== 'failed');
+ return {
+ status: anyLanded ? 'partial' : 'failed',
+ action: action.name,
+ steps: rows,
+ failedStep: { index, receipt },
+ };
+ }
+ // Truthfulness: a step that only PARTIALLY landed (or whose verification
+ // disagreed) must not roll up into a clean `succeeded`. Stop and report
+ // partial with the evidence โ later steps may depend on the missing part.
+ if (receipt.status === 'partial' || receipt.verificationPassed === false) {
+ return {
+ status: 'partial',
+ action: action.name,
+ steps: rows,
+ failedStep: { index, receipt },
+ };
+ }
+ }
+ return { status: 'succeeded', action: action.name, steps: rows };
+}
+
+/** Read the session revision off a client-side doc handle, if it can. */
+async function readRevision(documentHandle: BoundDocApi): Promise {
+ const info = (documentHandle as { info?: (params: Record) => Promise }).info;
+ if (typeof info !== 'function') return null;
+ try {
+ const result = (await info.call(documentHandle, {})) as { revision?: unknown };
+ return result?.revision == null ? null : String(result.revision);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * `run` tier โ execute the native function in the caller's process against the
+ * typed doc handle, synthesizing a truth-telling receipt: pre/post revision,
+ * and on failure whether a partial mutation was left behind + a recovery hint.
+ */
+async function runNativeAction(
+ action: ActionSpec,
+ documentHandle: BoundDocApi,
+ args: Record,
+): Promise> {
+ const vars = args; // defaults already applied by the router
+ const preRevision = await readRevision(documentHandle);
+ try {
+ const result = await action.run!(documentHandle, vars);
+ const postRevision = await readRevision(documentHandle);
+ return { status: 'succeeded', action: action.name, result, preRevision, postRevision };
+ } catch (error) {
+ const err = error as { code?: string; message?: string };
+ const postRevision = await readRevision(documentHandle);
+ const partialMutation = preRevision != null && postRevision != null && preRevision !== postRevision;
+ return {
+ status: 'failed',
+ action: action.name,
+ errors: [{ code: err.code ?? null, message: err.message ?? String(error) }],
+ preRevision,
+ postRevision,
+ partialMutation,
+ recovery: partialMutation
+ ? { kind: 'revert', call: 'superdoc_perform_action {action:"undo_changes"}' }
+ : { kind: 'retry' },
+ };
+ }
+}
+
+/** Validate args, then route the custom action to its execution tier. */
+async function runCustomAction(
+ base: PresetDescriptor,
+ action: ActionSpec,
+ documentHandle: BoundDocApi,
+ rawArgs: Record,
+ invokeOptions?: InvokeOptions,
+ fromPerformAction = true,
+): Promise> {
+ // `action` is the superdoc_perform_action discriminator ONLY on that route;
+ // in standalone mode the action is its own tool, so `action` may be a real
+ // declared argument โ strip it only when it came in as the discriminator.
+ const rawRest: Record = { ...rawArgs };
+ if (fromPerformAction) delete rawRest.action;
+ // Apply schema defaults, THEN validate โ a required arg with a declared
+ // default is satisfiable by the default.
+ const args = applyInputDefaults(action, rawRest);
+ validateAgainstSchema(action, args);
+ return executionKindOf(action) === 'steps'
+ ? runStepsAction(base, action, documentHandle, args, invokeOptions)
+ : runNativeAction(action, documentHandle, args);
+}
+
+// ---------------------------------------------------------------------------
+// Tool-list merging โ mirror the provider shapes from agent/catalog.ts
+// ---------------------------------------------------------------------------
+
+function toolNameOf(tool: unknown): string {
+ const t = tool as { name?: string; function?: { name?: string } };
+ return t?.function?.name ?? t?.name ?? '';
+}
+
+/**
+ * Re-apply the Anthropic prompt-cache marker after the tool list was mutated.
+ *
+ * The base preset places `cache_control: { type: 'ephemeral' }` on its LAST
+ * tool. Appending (standalone) or removing tools (composePreset) can leave the
+ * marker mid-list or drop it entirely. When the marker is meaningful โ provider
+ * is anthropic and cache was requested โ strip any existing `cache_control` and
+ * put it back on the final last tool so the cached prefix stays correct. No-op
+ * for other providers / when cache was not requested / on an empty list.
+ */
+function renormalizeAnthropicCacheMarker(tools: unknown[], provider: ToolProvider, cacheRequested: boolean): unknown[] {
+ if (provider !== 'anthropic' || !cacheRequested || tools.length === 0) return tools;
+ const stripped = tools.map((tool) => {
+ if (!isRecord(tool) || !('cache_control' in tool)) return tool;
+ const { cache_control: _drop, ...rest } = tool as Record;
+ return rest;
+ });
+ const last = stripped[stripped.length - 1];
+ stripped[stripped.length - 1] = isRecord(last) ? { ...last, cache_control: { type: 'ephemeral' } } : last;
+ return stripped;
+}
+
+function customActionsDescription(actions: readonly ActionSpec[]): string {
+ return ` Custom actions: ${actions.map((r) => `${r.name} (${r.description})`).join('; ')}.`;
+}
+
+/**
+ * Merge custom actions into the existing `superdoc_perform_action` tool: append names to
+ * the `action` enum, union inputSchema.properties into the tool's properties,
+ * and extend the description.
+ */
+/**
+ * Reject a custom arg whose name collides with an existing arg (built-in or an
+ * earlier custom action) of a DIFFERENT shape. Merging into one flat
+ * `superdoc_perform_action` schema means a single name โ one schema; silently
+ * keeping the first would advertise one shape for two meanings. Identical
+ * re-declarations (same JSON Schema) are allowed โ actions may share an arg.
+ */
+/** Order-insensitive JSON serialization: object keys sorted recursively (array
+ * order preserved โ it is semantic for `enum`/`required`). So two schemas that
+ * differ ONLY in key order compare equal. */
+function canonicalJson(value: unknown): string {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
+ const obj = value as Record;
+ const body = Object.keys(obj)
+ .sort()
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
+ .join(',');
+ return `{${body}}`;
+}
+
+/** Documentation-only JSON Schema keywords โ differences here never conflict. */
+const METADATA_SCHEMA_KEYS: ReadonlySet = new Set(['description', 'title', 'examples', '$comment']);
+
+/** Drop doc-only keys (recursively) so comparison sees just the structural shape. */
+function structuralSchema(value: unknown): unknown {
+ if (value === null || typeof value !== 'object') return value;
+ if (Array.isArray(value)) return value.map(structuralSchema);
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ if (METADATA_SCHEMA_KEYS.has(k)) continue;
+ out[k] = structuralSchema(v);
+ }
+ return out;
+}
+
+/** Two arg schemas CONFLICT when they differ in any way EXCEPT documentation
+ * (description/title/examples/$comment): reusing a built-in arg name with your
+ * own description is fine, but a different type, enum (incl. one-sided),
+ * default, limit, pattern, or nested shape is a real conflict โ the merged
+ * surface advertises ONE schema per name. */
+function argSchemasConflict(a: unknown, b: unknown): boolean {
+ return canonicalJson(structuralSchema(a)) !== canonicalJson(structuralSchema(b));
+}
+
+function assertNoArgConflict(
+ properties: Record,
+ key: string,
+ value: unknown,
+ actionName: string,
+): void {
+ if (key in properties && argSchemasConflict(properties[key], value)) {
+ throw new SuperDocCliError(
+ `Custom action "${actionName}" declares argument "${key}" with a schema that conflicts with an existing ` +
+ `argument of the same name on the superdoc_perform_action surface (they differ beyond description). Rename the argument, or match the existing schema exactly.`,
+ { code: 'INVALID_ARGUMENT', details: { action: actionName, arg: key } },
+ );
+ }
+}
+
+function mergeIntoAgentAction(tools: unknown[], actions: readonly ActionSpec[]): unknown[] {
+ return tools.map((tool) => {
+ if (toolNameOf(tool) !== 'superdoc_perform_action') return tool;
+ const t = tool as Record;
+ // Provider dialects: openai nests under `function` (`parameters`);
+ // anthropic is flat with `input_schema`; the core agent dialect for
+ // vercel is flat with `inputSchema`; generic is flat with `parameters`.
+ const fn = isRecord(t.function) ? (t.function as Record) : null;
+ const schemaContainer = fn ?? t;
+ const schemaKey =
+ 'input_schema' in schemaContainer
+ ? 'input_schema'
+ : 'inputSchema' in schemaContainer
+ ? 'inputSchema'
+ : 'parameters';
+ const schema = isRecord(schemaContainer[schemaKey]) ? (schemaContainer[schemaKey] as Record) : {};
+ const properties = isRecord(schema.properties) ? { ...(schema.properties as Record) } : {};
+ const actionProp = isRecord(properties.action) ? { ...(properties.action as Record) } : {};
+ const enumValues = Array.isArray(actionProp.enum) ? [...(actionProp.enum as unknown[])] : [];
+
+ for (const action of actions) {
+ if (!enumValues.includes(action.name)) enumValues.push(action.name);
+ for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) {
+ assertNoArgConflict(properties, key, value, action.name);
+ if (!(key in properties)) properties[key] = value;
+ }
+ }
+ const nextActionProp = { ...actionProp, enum: enumValues };
+ const nextSchema = { ...schema, properties: { ...properties, action: nextActionProp } };
+ const baseDescription =
+ typeof schemaContainer.description === 'string' ? (schemaContainer.description as string) : '';
+ const nextDescription = baseDescription + customActionsDescription(actions);
+ const nextContainer = { ...schemaContainer, description: nextDescription, [schemaKey]: nextSchema };
+ return fn ? { ...t, function: nextContainer } : nextContainer;
+ });
+}
+
+/**
+ * When the base dropped `superdoc_perform_action` entirely (all built-ins
+ * excluded / an empty allowlist), active custom actions would be advertised in
+ * the prompt and dispatchable โ but carried by NO tool. Synthesize a
+ * custom-only definition so a curated custom-only preset stays callable.
+ */
+function synthesizePerformAction(provider: ToolProvider, actions: readonly ActionSpec[]): unknown {
+ const properties: Record = {
+ action: { type: 'string', enum: actions.map((action) => action.name) },
+ };
+ for (const action of actions) {
+ for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) {
+ assertNoArgConflict(properties, key, value, action.name);
+ if (!(key in properties)) properties[key] = value;
+ }
+ }
+ const schema = { type: 'object', additionalProperties: true, required: ['action'], properties };
+ const description =
+ "Perform one of this preset's custom document actions. Pick an action and pass its flat arguments." +
+ customActionsDescription(actions);
+ if (provider === 'anthropic') {
+ return { name: 'superdoc_perform_action', description, input_schema: schema };
+ }
+ if (provider === 'vercel') {
+ return { name: 'superdoc_perform_action', description, inputSchema: schema };
+ }
+ if (provider === 'openai') {
+ return { type: 'function', function: { name: 'superdoc_perform_action', description, parameters: schema } };
+ }
+ return { name: 'superdoc_perform_action', description, parameters: schema };
+}
+
+/** Merge into an existing perform_action tool, or synthesize one if the base dropped it. */
+function mergeOrSynthesizePerformAction(
+ tools: unknown[],
+ actions: readonly ActionSpec[],
+ provider: ToolProvider,
+): unknown[] {
+ const hasPerformAction = tools.some((tool) => toolNameOf(tool) === 'superdoc_perform_action');
+ if (hasPerformAction) return mergeIntoAgentAction(tools, actions);
+ return [...tools, synthesizePerformAction(provider, actions)];
+}
+
+/** Build a single provider-shaped standalone tool for a custom action. */
+function standaloneTool(provider: ToolProvider, action: ActionSpec): unknown {
+ if (provider === 'anthropic') {
+ return { name: action.name, description: action.description, input_schema: action.inputSchema };
+ }
+ // The core agent dialect for vercel is FLAT {name, description, inputSchema}
+ // (agent/catalog.ts toVercelTool) โ not the OpenAI nested function shape.
+ if (provider === 'vercel') {
+ return { name: action.name, description: action.description, inputSchema: action.inputSchema };
+ }
+ if (provider === 'openai') {
+ return {
+ type: 'function',
+ function: { name: action.name, description: action.description, parameters: action.inputSchema },
+ };
+ }
+ // generic
+ return { name: action.name, description: action.description, parameters: action.inputSchema };
+}
+
+// ---------------------------------------------------------------------------
+// extendPreset โ wrap a base preset with custom actions
+// ---------------------------------------------------------------------------
+
+export interface ExtendPresetOptions {
+ /** New preset id. */
+ id: string;
+ /** Optional description override. */
+ description?: string;
+ /** Custom actions to add. */
+ actions: readonly ActionSpec[];
+ /** Wholesale system-prompt addendum; defaults to an auto-generated bullet list. */
+ systemPromptExtra?: string;
+ /** When true, expose each action as its own tool instead of merging into superdoc_perform_action. */
+ standalone?: boolean;
+}
+
+function autoSystemPromptSection(actions: readonly ActionSpec[]): string {
+ if (actions.length === 0) return '';
+ const bullets = actions.map((r) => `- ${r.name} โ ${r.description}`).join('\n');
+ return `\n\n## Custom actions\n${bullets}`;
+}
+
+/**
+ * Wrap `getPreset(baseId)` with custom actions. Returns a new
+ * {@link PresetDescriptor} that advertises and dispatches the custom actions
+ * while delegating everything else to the base preset.
+ */
+export function extendPreset(baseId: string, options: ExtendPresetOptions): PresetDescriptor {
+ const base = getPreset(baseId);
+ // Snapshot the caller's array โ the preset surface must stay immutable even
+ // if discovery/hot-reload code mutates the original list after construction
+ // (else getTools/prompt would drift from the byName dispatch map).
+ const actions = options.actions ? [...options.actions] : [];
+ const standalone = options.standalone === true;
+ assertActionsValid(actions, options.id, standalone);
+ const byName = new Map(actions.map((r) => [r.name, r] as const));
+
+ const splitExclusions = (list: readonly string[] | undefined) => splitCustomExclusions(byName, list);
+
+ async function getTools(provider: ToolProvider, toolOptions?: GetToolsOptions): Promise {
+ const { customExcluded, builtinExcluded } = splitExclusions(toolOptions?.excludeActions);
+ const baseOptions = toolOptions ? { ...toolOptions, excludeActions: builtinExcluded } : undefined;
+ const result = await base.getTools(provider, baseOptions);
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
+ if (activeActions.length === 0) return result;
+ // Only re-place the anthropic marker when the base actually applied one โ
+ // marking tools for a base that reported 'disabled' would make the marker
+ // and the cacheStrategy metadata disagree.
+ const cacheRequested = toolOptions?.cache === true && result.cacheStrategy !== 'disabled';
+ if (standalone) {
+ const extra = activeActions.map((action) => standaloneTool(provider, action));
+ const tools = renormalizeAnthropicCacheMarker([...result.tools, ...extra], provider, cacheRequested);
+ return { ...result, tools };
+ }
+ // Merge keeps the same tool count/order, so the marker is unaffected โ but
+ // re-normalize anyway to stay correct if a base ever reorders.
+ const merged = renormalizeAnthropicCacheMarker(
+ mergeOrSynthesizePerformAction(result.tools, activeActions, provider),
+ provider,
+ cacheRequested,
+ );
+ return { ...result, tools: merged };
+ }
+
+ async function getCatalog(): Promise {
+ const catalog = await base.getCatalog();
+ const extraRows: ToolCatalogEntry[] = actions.map((action) => ({
+ toolName: action.name,
+ description: action.description,
+ inputSchema: action.inputSchema as unknown as Record,
+ mutates: true,
+ operations: [],
+ }));
+ const tools = [...catalog.tools, ...extraRows];
+ return { ...catalog, toolCount: tools.length, tools };
+ }
+
+ async function getSystemPrompt(promptOptions?: GetSystemPromptOptions): Promise {
+ const { customExcluded, builtinExcluded } = splitExclusions(promptOptions?.excludeActions);
+ const basePrompt = await base.getSystemPrompt(
+ builtinExcluded ? { ...promptOptions, excludeActions: builtinExcluded } : undefined,
+ );
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
+ const extra = options.systemPromptExtra ?? autoSystemPromptSection(activeActions);
+ return basePrompt + extra;
+ }
+
+ async function getMcpPrompt(): Promise {
+ const basePrompt = await base.getMcpPrompt();
+ const extra = options.systemPromptExtra ?? autoSystemPromptSection(actions);
+ return basePrompt + extra;
+ }
+
+ async function dispatch(
+ documentHandle: BoundDocApi,
+ toolName: string,
+ args: Record,
+ invokeOptions?: InvokeOptions,
+ ): Promise {
+ // Defense-in-depth parity with core: a host that narrowed the advertised
+ // surface passes the same exclusions here, and an excluded CUSTOM action
+ // must be refused before it runs (the base can only refuse built-ins).
+ const exclusionOptions = invokeOptions as (InvokeOptions & { excludeActions?: readonly string[] }) | undefined;
+ const excluded = new Set(exclusionOptions?.excludeActions ?? []);
+ // Advertised surface == dispatchable surface: in standalone mode custom
+ // actions are advertised as their own tools, so the (unadvertised)
+ // perform_action route must not execute them โ it falls through to the
+ // base, which rejects the unknown action name.
+ if (!standalone && toolName === 'superdoc_perform_action' && isRecord(args) && typeof args.action === 'string') {
+ const action = byName.get(args.action);
+ if (action) {
+ if (excluded.has(action.name)) throwExcludedAction(toolName, action.name);
+ return runCustomAction(base, action, documentHandle, args, invokeOptions);
+ }
+ }
+ if (standalone && byName.has(toolName)) {
+ if (excluded.has(toolName)) throwExcludedAction(toolName, toolName);
+ const action = byName.get(toolName)!;
+ return runCustomAction(base, action, documentHandle, args, invokeOptions, /* fromPerformAction */ false);
+ }
+ return base.dispatch(documentHandle, toolName, args, invokeOptions);
+ }
+
+ return {
+ id: options.id,
+ description: options.description ?? `${base.description} + ${actions.length} custom action(s).`,
+ supportsCacheControl: base.supportsCacheControl,
+ getTools,
+ getCatalog,
+ getSystemPrompt,
+ getMcpPrompt,
+ dispatch,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// composePreset โ build a preset over core, filtering its surface
+// ---------------------------------------------------------------------------
+
+export interface ComposePresetOptions {
+ id: string;
+ baseId?: string;
+ /** When given, restrict the superdoc_perform_action enum to these built-in names โช custom names. */
+ includeCoreActions?: readonly string[];
+ /** When explicitly false, drop the superdoc_execute_code tool from the advertised tools. */
+ includeExecuteCode?: boolean;
+ actions?: readonly ActionSpec[];
+ /** Wholesale system prompt; when omitted, base + custom is used. */
+ systemPrompt?: string;
+ description?: string;
+}
+
+/**
+ * Compose a preset over `core` (or another base): optionally filter the
+ * `superdoc_perform_action` enum to a subset of built-in actions (โช custom names), and/or
+ * drop `superdoc_execute_code` from the advertised surface. Custom actions still
+ * dispatch via the superdoc_execute_code rewrite.
+ */
+export function composePreset(options: ComposePresetOptions): PresetDescriptor {
+ const baseId = options.baseId ?? 'core';
+ // Snapshot the caller's array โ the preset surface must stay immutable even
+ // if discovery/hot-reload code mutates the original list after construction
+ // (else getTools/prompt would drift from the byName dispatch map).
+ const actions = options.actions ? [...options.actions] : [];
+ assertActionsValid(actions, options.id);
+ const base = getPreset(baseId);
+ const byName = new Map(actions.map((r) => [r.name, r] as const));
+ const includeCore = options.includeCoreActions != null ? new Set(options.includeCoreActions) : null;
+ if (includeCore) {
+ for (const name of includeCore) {
+ if (!BUILTIN_ACTION_NAMES.has(name)) {
+ throw new SuperDocCliError(`includeCoreActions: unknown action "${name}".`, {
+ code: 'INVALID_ARGUMENT',
+ details: { presetId: options.id, unknownAction: name },
+ });
+ }
+ }
+ }
+ const dropExecuteCode = options.includeExecuteCode === false;
+
+ const splitExclusions = (list: readonly string[] | undefined) => splitCustomExclusions(byName, list);
+
+ async function getTools(provider: ToolProvider, toolOptions?: GetToolsOptions): Promise {
+ const { customExcluded, builtinExcluded } = splitExclusions(toolOptions?.excludeActions);
+ // The allowlist is implemented as a DERIVED exclusion forwarded to the
+ // base, so core narrows the enum, the grouped description, AND the
+ // advertised argument properties natively โ no hand-rebuilt schemas here
+ // (a second implementation of that narrowing is exactly what drifts).
+ const derivedExcludes = includeCore ? ACTION_NAMES_LIST.filter((name) => !includeCore.has(name)) : [];
+ const mergedExcludes = [...new Set([...derivedExcludes, ...(builtinExcluded ?? [])])];
+ const baseOptions = {
+ ...(toolOptions ?? {}),
+ excludeActions: mergedExcludes.length > 0 ? mergedExcludes : undefined,
+ };
+ const result = await base.getTools(provider, baseOptions);
+ let tools = result.tools;
+ if (dropExecuteCode) tools = tools.filter((t) => toolNameOf(t) !== 'superdoc_execute_code');
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
+ if (activeActions.length > 0) tools = mergeOrSynthesizePerformAction(tools, activeActions, provider);
+ // Dropping superdoc_execute_code can strip the marker off the (former) last tool;
+ // re-normalize so the anthropic cached prefix is still correct.
+ tools = renormalizeAnthropicCacheMarker(
+ tools,
+ provider,
+ toolOptions?.cache === true && result.cacheStrategy !== 'disabled',
+ );
+ return { ...result, tools };
+ }
+
+ async function getCatalog(): Promise {
+ const catalog = await base.getCatalog();
+ let rows = catalog.tools;
+ if (dropExecuteCode) rows = rows.filter((row) => row.toolName !== 'superdoc_execute_code');
+ // Advertised == dispatchable, catalog included: when includeCoreActions
+ // narrows the surface, the catalog's superdoc_perform_action row must narrow
+ // WITH it โ enum, description, AND argument properties โ or getToolCatalog()
+ // still advertises inputs for actions the preset refuses. Rebuild the row
+ // from the SAME builder getTools drives through the base (buildPerform-
+ // ActionDefinition), so there is one narrowing implementation, not a second
+ // that drifts. Canonical ACTION_NAMES_LIST order matches the getTools row.
+ if (includeCore != null) {
+ const includedBuiltins = ACTION_NAMES_LIST.filter((name) => includeCore.has(name));
+ if (includedBuiltins.length === 0) {
+ rows = rows.filter((row) => row.toolName !== 'superdoc_perform_action');
+ } else {
+ const def = buildPerformActionDefinition(includedBuiltins);
+ rows = rows.map((row) =>
+ row.toolName === 'superdoc_perform_action'
+ ? {
+ ...row,
+ description: def.description,
+ inputSchema: def.inputSchema as unknown as Record,
+ }
+ : row,
+ );
+ }
+ }
+ const extraRows: ToolCatalogEntry[] = actions.map((action) => ({
+ toolName: action.name,
+ description: action.description,
+ inputSchema: action.inputSchema as unknown as Record,
+ mutates: true,
+ operations: [],
+ }));
+ const tools = [...rows, ...extraRows];
+ return { ...catalog, toolCount: tools.length, tools };
+ }
+
+ async function getSystemPrompt(promptOptions?: GetSystemPromptOptions): Promise {
+ if (typeof options.systemPrompt === 'string') return options.systemPrompt;
+ // includeCoreActions narrows the ENUM; the prompt must narrow WITH it โ
+ // a per-action manual for an uncallable action teaches the model to call
+ // it. Derive the excluded built-ins and let the base strip their lines;
+ // requested exclusions may also name CUSTOM actions (handled here).
+ const { customExcluded, builtinExcluded } = splitExclusions(promptOptions?.excludeActions);
+ const derivedExcludes = includeCore ? ACTION_NAMES_LIST.filter((name) => !includeCore.has(name)) : [];
+ const merged = [...new Set([...derivedExcludes, ...(builtinExcluded ?? [])])];
+ const basePrompt = await base.getSystemPrompt(merged.length > 0 ? { excludeActions: merged } : undefined);
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
+ return basePrompt + autoSystemPromptSection(activeActions);
+ }
+
+ async function getMcpPrompt(): Promise {
+ if (typeof options.systemPrompt === 'string') return options.systemPrompt;
+ const basePrompt = await base.getMcpPrompt();
+ return basePrompt + autoSystemPromptSection(actions);
+ }
+
+ async function dispatch(
+ documentHandle: BoundDocApi,
+ toolName: string,
+ args: Record,
+ invokeOptions?: InvokeOptions,
+ ): Promise {
+ if (toolName === 'superdoc_perform_action' && isRecord(args) && typeof args.action === 'string') {
+ const action = byName.get(args.action);
+ if (action) {
+ const exclusionOptions = invokeOptions as (InvokeOptions & { excludeActions?: readonly string[] }) | undefined;
+ if (exclusionOptions?.excludeActions?.includes(action.name)) {
+ throwExcludedAction(toolName, action.name);
+ }
+ return runCustomAction(base, action, documentHandle, args, invokeOptions);
+ }
+ // Composition allowlist is a dispatch boundary too: a built-in outside
+ // includeCoreActions is not advertised and must not execute on a
+ // guessed/stale call (defense-in-depth parity with excludeActions).
+ if (includeCore && BUILTIN_ACTION_NAMES.has(args.action) && !includeCore.has(args.action)) {
+ throwExcludedAction(toolName, args.action, { excludedBy: 'includeCoreActions' });
+ }
+ }
+ // superdoc_execute_code stays dispatchable internally even when not advertised
+ // (mirrors core-actions.ts): the base dispatch still routes it.
+ return base.dispatch(documentHandle, toolName, args, invokeOptions);
+ }
+
+ return {
+ id: options.id,
+ description: options.description ?? `Composed over ${baseId} with ${actions.length} custom action(s).`,
+ supportsCacheControl: base.supportsCacheControl,
+ getTools,
+ getCatalog,
+ getSystemPrompt,
+ getMcpPrompt,
+ dispatch,
+ };
+}
diff --git a/packages/sdk/langs/node/src/agent/catalog.ts b/packages/sdk/langs/node/src/agent/catalog.ts
index ac3c1b3fa3..3b94c6aec2 100644
--- a/packages/sdk/langs/node/src/agent/catalog.ts
+++ b/packages/sdk/langs/node/src/agent/catalog.ts
@@ -293,7 +293,7 @@ function buildActionDescription(included?: ReadonlySet): string {
* description, AND the advertised argument properties all shrink together
* (an arg no remaining action declares is not advertised).
*/
-function buildPerformActionDefinition(includedActions: readonly string[]): AgentToolDefinition {
+export function buildPerformActionDefinition(includedActions: readonly string[]): AgentToolDefinition {
const included = new Set(includedActions);
return {
name: 'superdoc_perform_action',
diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts
index cee1c01ae8..e2d5ec0e08 100644
--- a/packages/sdk/langs/node/src/index.ts
+++ b/packages/sdk/langs/node/src/index.ts
@@ -260,6 +260,17 @@ export type { AgentReceipt } from './agent/runtime.js';
export type { GetSystemPromptOptions, GetToolsOptions, GetToolsResult, PresetDescriptor } from './presets.js';
export { createAgentToolkit } from './tools.js';
export type { AgentToolkit, CreateAgentToolkitInput } from './tools.js';
+// Custom-action authoring kit: define actions in two execution tiers
+// (steps / run) and extend or compose presets with them.
+export { defineAction, extendPreset, composePreset, executionKindOf } from './actions/define.js';
+export type {
+ ActionSpec,
+ ActionStep,
+ DefineActionInput,
+ ExtendPresetOptions,
+ ComposePresetOptions,
+ JSONSchemaObject,
+} from './actions/define.js';
export { SuperDocCliError } from './runtime/errors.js';
export type {
InvokeOptions,
diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts
index a2ae9e0f79..6a7d5a9ba1 100644
--- a/packages/sdk/langs/node/src/tools.ts
+++ b/packages/sdk/langs/node/src/tools.ts
@@ -23,6 +23,7 @@ import {
type ToolCatalogOperation,
type ToolProvider,
} from './presets.js';
+import { extendPreset, composePreset, type ActionSpec } from './actions/define.js';
export { DEFAULT_PRESET, getPreset, listPresets, registerPreset, unregisterPreset };
export type {
@@ -185,7 +186,26 @@ function resolvePromptPresetArg(preset?: string | { preset?: string }): string {
* suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). For MCP
* server instructions, use {@link getMcpPrompt} instead.
*/
-export type CreateAgentToolkitInput = ToolChooserInput;
+export type CreateAgentToolkitInput = ToolChooserInput & {
+ /**
+ * Custom actions to expose alongside the base preset's built-ins. When set,
+ * the toolkit builds the extended preset FOR YOU โ no `registerPreset`, no
+ * preset id to invent or thread through later `dispatch` calls. This is the
+ * simple path: define your actions, hand them here, use the returned toolkit.
+ * Advanced callers can still `extendPreset`/`composePreset` + `preset`.
+ */
+ actions?: readonly ActionSpec[];
+ /** Base preset to extend when `actions` is set (default `'core'`). Ignored otherwise. */
+ base?: string;
+ /**
+ * With `actions`, keep only these built-in action names (โช your custom
+ * actions) โ builds via `composePreset` instead of `extendPreset`.
+ */
+ includeCoreActions?: readonly string[];
+};
+
+/** Label for the ephemeral preset built by the one-call `actions` path. */
+const CUSTOM_TOOLKIT_PRESET_ID = 'custom_superdoc_preset';
export type AgentToolkit = {
/** Provider-shaped tool definitions (see {@link chooseTools}). */
@@ -222,8 +242,45 @@ export type AgentToolkit = {
* matching the standalone functions.
*/
export async function createAgentToolkit(input: CreateAgentToolkitInput): Promise {
- const presetId = input.preset ?? DEFAULT_PRESET;
const excludeActions = input.excludeActions ? [...input.excludeActions] : undefined;
+
+ // One-call custom-actions path: define your actions, hand them here, use the
+ // returned toolkit. We build an ephemeral extended/composed preset over
+ // `base` (default 'core') and drive it directly โ no global registerPreset,
+ // and nothing for the caller to remember at dispatch time.
+ if ((input.actions && input.actions.length > 0) || input.includeCoreActions != null) {
+ // `preset` doubles as the base to extend here (so `{ preset: 'core', actions }`
+ // reads naturally); `base` wins if both are given. Default base is 'core'.
+ const baseId = input.base ?? input.preset ?? 'core';
+ const actions = input.actions ?? [];
+ const descriptor =
+ input.includeCoreActions != null
+ ? composePreset({
+ id: CUSTOM_TOOLKIT_PRESET_ID,
+ baseId,
+ includeCoreActions: input.includeCoreActions,
+ actions,
+ })
+ : extendPreset(baseId, { id: CUSTOM_TOOLKIT_PRESET_ID, actions });
+ const { tools, cacheStrategy } = await descriptor.getTools(input.provider, {
+ cache: input.cache === true,
+ excludeActions,
+ });
+ const systemPrompt = await descriptor.getSystemPrompt(excludeActions ? { excludeActions } : undefined);
+ const dispatch: AgentToolkit['dispatch'] = (documentHandle, toolName, args = {}, invokeOptions) =>
+ descriptor.dispatch(documentHandle, toolName, args, {
+ ...invokeOptions,
+ ...(excludeActions ? { excludeActions } : {}),
+ });
+ return {
+ tools,
+ meta: { provider: input.provider, preset: descriptor.id, toolCount: tools.length, cacheStrategy },
+ systemPrompt,
+ dispatch,
+ };
+ }
+
+ const presetId = input.preset ?? DEFAULT_PRESET;
const { tools, meta } = await chooseTools({ ...input, preset: presetId, excludeActions });
const systemPrompt = await getSystemPrompt(presetId, excludeActions ? { excludeActions } : undefined);
const dispatch: AgentToolkit['dispatch'] = (documentHandle, toolName, args = {}, invokeOptions) =>
diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py
index 9dc84d08f7..e0567d2859 100644
--- a/packages/sdk/langs/python/superdoc/__init__.py
+++ b/packages/sdk/langs/python/superdoc/__init__.py
@@ -1,4 +1,5 @@
from .presets import DEFAULT_PRESET, get_preset, list_presets, register_preset, unregister_preset
+from .presets.custom import compose_preset, define_action, extend_preset
from .client import AsyncSuperDocClient, AsyncSuperDocDocument, SuperDocClient, SuperDocDocument
from .errors import SuperDocError
from .skill_api import get_skill, install_skill, list_skills
@@ -37,4 +38,7 @@
"list_presets",
"register_preset",
"unregister_preset",
+ "define_action",
+ "extend_preset",
+ "compose_preset",
]
diff --git a/packages/sdk/langs/python/superdoc/presets/__init__.py b/packages/sdk/langs/python/superdoc/presets/__init__.py
index b089cfcb4e..7a5ed27daa 100644
--- a/packages/sdk/langs/python/superdoc/presets/__init__.py
+++ b/packages/sdk/langs/python/superdoc/presets/__init__.py
@@ -33,9 +33,10 @@ class PresetDescriptor(Protocol):
description: str
supports_cache_control: bool
- def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: ...
+ def get_tools(self, provider: ToolProvider, *, cache: bool = False,
+ exclude_actions: Optional[list] = None) -> Dict[str, Any]: ...
def get_catalog(self) -> Dict[str, Any]: ...
- def get_system_prompt(self) -> str: ...
+ def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str: ...
def get_mcp_prompt(self) -> str: ...
def dispatch(
self,
diff --git a/packages/sdk/langs/python/superdoc/presets/custom.py b/packages/sdk/langs/python/superdoc/presets/custom.py
new file mode 100644
index 0000000000..24fbdf79b6
--- /dev/null
+++ b/packages/sdk/langs/python/superdoc/presets/custom.py
@@ -0,0 +1,1183 @@
+"""Customer-extensible **custom actions** (Python mirror of the Node SDK kit).
+
+The canonical ActionSpec has exactly ONE execution tier:
+
+- ``steps`` โ declarative composition of built-in core actions with ``{{arg}}``
+ templating; dispatches through the base preset and inherits its target
+ resolution, receipts, and verification.
+- ``run`` โ a native Python callable executed in YOUR process against the
+ typed doc handle, with synthesized truth-telling receipts (pre/post
+ revision, partialMutation). Async callables require the async dispatcher.
+
+(A third, in-host tier lands with the code-act execution path; see the Node
+``define.ts`` module header for the rationale.)
+
+``extend_preset``/``compose_preset`` merge custom actions into the
+superdoc_perform_action enum, tool description, system prompt, and dispatch
+COHERENTLY โ including ``exclude_actions``, which may name built-in actions
+(forwarded to the base) or custom ones (handled by the wrapper).
+
+Cross-runtime contract: templating semantics, input-schema defaults, and
+receipt shapes are identical to the Node kit
+(``langs/node/src/actions/define.ts``) for any JSON-serializable tool input.
+
+Author flow::
+
+ add = define_action(
+ name='superdoc.add_footnote',
+ description='Insert a footnote right after the anchor text.',
+ input_schema={'type': 'object', 'properties': {...}, 'required': ['anchorText', 'content']},
+ run=_add_footnote,
+ )
+ acme = extend_preset('core', id='acme', actions=[add])
+ register_preset(acme)
+ choose_tools({'provider': 'openai', 'preset': 'acme'})
+ dispatch_superdoc_tool(handle, 'superdoc_perform_action', {'action': 'superdoc.add_footnote', ...}, preset='acme')
+"""
+
+from __future__ import annotations
+
+import inspect
+import json
+import re
+from dataclasses import dataclass
+from typing import Any, Awaitable, Dict, List, Optional, Sequence
+
+from ..errors import SuperDocError
+from . import ToolProvider, get_preset
+
+# ---------------------------------------------------------------------------
+# Built-in core action names. MUST stay in sync with ACTION_NAMES_LIST in
+# node/src/agent/actions.ts (source of truth). Collision checks compare against
+# this set. (core's getCatalog lists the 3 tools, not the 35 action names, so a
+# shared constant is the safest cross-runtime source (40 names). A unit test
+# asserts the exact set so any drift from Node fails loudly.
+# ---------------------------------------------------------------------------
+
+BUILTIN_ACTION_NAMES: frozenset = frozenset(
+ {
+ 'insert_paragraphs',
+ 'insert_heading',
+ 'replace_text',
+ 'delete_text',
+ 'append_list',
+ 'create_table',
+ 'comment_paragraphs',
+ 'add_comments',
+ 'resolve_comments',
+ 'reply_to_comment',
+ 'rewrite_block',
+ 'accept_tracked_changes',
+ 'reject_tracked_changes',
+ 'normalize_body_font_size',
+ 'set_font_family',
+ 'apply_letter_spacing',
+ 'fill_placeholders',
+ 'move_range',
+ 'insert_toc',
+ 'insert_table_row',
+ 'insert_table_column',
+ 'delete_table_row',
+ 'delete_table_column',
+ 'split_table',
+ 'convert_list',
+ 'split_list',
+ 'undo_changes',
+ 'redo_changes',
+ 'attach_numbering',
+ 'add_list_items',
+ 'format_text',
+ 'apply_style',
+ 'format_paragraph',
+ 'move_text',
+ 'style_table',
+ 'move_table',
+ 'delete_table',
+ 'set_paragraph_spacing',
+ 'insert_page_break',
+ 'add_hyperlink',
+ }
+)
+
+
+# ---------------------------------------------------------------------------
+# define_action โ author a ActionSpec (a plain dict)
+# ---------------------------------------------------------------------------
+
+
+def define_action(
+ name: str,
+ description: str,
+ input_schema: Optional[Dict[str, Any]] = None,
+ steps: Optional[Sequence[Dict[str, Any]]] = None,
+ run: Optional[Any] = None,
+) -> Dict[str, Any]:
+ """Author an ActionSpec dict โ the canonical custom-action type.
+
+ ``input_schema`` is a JSON Schema object describing the action's flat args
+ (NOT the ``action`` discriminator). Exactly ONE execution tier is given:
+
+ - ``steps`` โ DECLARATIVE (recommended): a list of built-in core actions
+ ``{'action': , 'args': {...}}`` with ``{{arg}}`` templating. Runs
+ through the base preset, inheriting target resolution, placement,
+ receipts, and verification. Pure data โ identical semantics from Node.
+ - ``run`` โ NATIVE escape hatch: a Python callable ``run(doc, args)``
+ executed in YOUR process against the typed doc handle.
+ """
+ if not isinstance(name, str) or not name:
+ raise SuperDocError('define_action requires a non-empty name.', code='INVALID_ARGUMENT')
+ if not isinstance(description, str):
+ raise SuperDocError(
+ f'define_action "{name}" requires a string description.',
+ code='INVALID_ARGUMENT',
+ details={'name': name},
+ )
+ tiers = []
+ if steps is not None:
+ tiers.append('steps')
+ if run is not None:
+ tiers.append('run')
+ if len(tiers) != 1:
+ raise SuperDocError(
+ f'define_action "{name}" requires exactly one of steps or run '
+ f"(got {'none' if not tiers else ' + '.join(tiers)}).",
+ code='INVALID_ARGUMENT',
+ details={'name': name, 'tiers': tiers},
+ )
+ if input_schema is None:
+ schema: Dict[str, Any] = {'type': 'object', 'properties': {}, 'additionalProperties': True}
+ elif isinstance(input_schema, dict):
+ # A proper JSON Schema object passes through; a bare properties bag is
+ # wrapped (matches Node's coerceInputSchema).
+ schema = (
+ input_schema
+ if input_schema.get('type') == 'object'
+ else {'type': 'object', 'properties': input_schema, 'additionalProperties': True}
+ )
+ else:
+ # Reject Zod/Pydantic/other schema objects clearly rather than silently
+ # dropping them for an empty schema.
+ raise SuperDocError(
+ f'define_action "{name}": input_schema must be a JSON Schema dict, not a '
+ f'{type(input_schema).__name__}. Convert a Zod/Pydantic/other schema to a JSON '
+ 'Schema dict first (e.g. Model.model_json_schema()).',
+ code='INVALID_ARGUMENT',
+ details={'name': name},
+ )
+ spec: Dict[str, Any] = {
+ 'name': name,
+ 'description': description,
+ 'inputSchema': schema,
+ }
+ if tiers[0] == 'steps':
+ if not isinstance(steps, (list, tuple)) or len(steps) == 0:
+ raise SuperDocError(
+ f'define_action "{name}": steps must be a non-empty list.',
+ code='INVALID_ARGUMENT',
+ details={'name': name},
+ )
+ normalized = []
+ for index, step in enumerate(steps):
+ action_name = step.get('action') if isinstance(step, dict) else None
+ if not isinstance(action_name, str) or not action_name:
+ raise SuperDocError(
+ f'define_action "{name}": steps[{index}] needs a non-empty "action" string.',
+ code='INVALID_ARGUMENT',
+ details={'name': name, 'index': index},
+ )
+ if action_name not in BUILTIN_ACTION_NAMES:
+ raise SuperDocError(
+ f'define_action "{name}": steps[{index}].action "{action_name}" is not a built-in '
+ 'core action. Steps compose built-in actions only; use run for anything else.',
+ code='INVALID_ARGUMENT',
+ details={'name': name, 'index': index, 'action': action_name},
+ )
+ step_args = step.get('args')
+ if step_args is not None and not isinstance(step_args, dict):
+ raise SuperDocError(
+ f'define_action "{name}": steps[{index}].args must be an object.',
+ code='INVALID_ARGUMENT',
+ details={'name': name, 'index': index},
+ )
+ normalized.append({'action': action_name, 'args': dict(step_args or {})})
+ spec['steps'] = normalized
+ elif tiers[0] == 'run':
+ if not callable(run):
+ raise SuperDocError(
+ f'define_action "{name}": run must be callable.',
+ code='INVALID_ARGUMENT',
+ details={'name': name},
+ )
+ spec['run'] = run
+ return spec
+
+
+def execution_kind_of(spec: Dict[str, Any]) -> str:
+ """Which execution tier a spec uses: 'steps' | 'run'."""
+ return 'steps' if isinstance(spec.get('steps'), (list, tuple)) else 'run'
+
+
+# ---------------------------------------------------------------------------
+# Codegen โ identical to the Node template for JSON-serializable tool inputs
+# (the only values a tool call can carry; NaN/Infinity/lone-surrogates are out
+# of scope and serialize differently across Node and Python).
+# ---------------------------------------------------------------------------
+
+
+
+
+# ---------------------------------------------------------------------------
+# Collision / duplicate validation
+# ---------------------------------------------------------------------------
+
+
+# Provider tool-name rule. OpenAI/Anthropic require tool names to match
+# ^[A-Za-z0-9_-]{1,64}$ โ dots are invalid. This only matters in STANDALONE
+# mode, where the action name becomes a tool name; in MERGED mode the name is
+# an enum VALUE (dots are fine).
+_PROVIDER_SAFE_TOOL_NAME = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
+
+# Tool names of the agent surface itself โ a custom action must never shadow
+# one (MUST stay in sync with AGENT_TOOL_NAMES in node/src/agent/catalog.ts).
+_RESERVED_TOOL_NAMES = frozenset({
+ 'superdoc_inspect',
+ 'superdoc_perform_action',
+ 'superdoc_execute_code',
+ 'agent_apply',
+ 'agent_verify',
+ 'agent_operation',
+})
+
+
+def _assert_actions_valid(
+ actions: Sequence[Dict[str, Any]], preset_id: str, standalone: bool = False
+) -> None:
+ seen = set()
+ for action in actions:
+ name = action.get('name')
+ # Raw spec dicts can bypass define_action โ re-validate the tier shape
+ # here so a hand-rolled {'steps': []} can't fabricate succeeded receipts.
+ tiers = [t for t, present in (
+ ('steps', isinstance(action.get('steps'), (list, tuple))),
+ ('run', callable(action.get('run'))),
+ ) if present]
+ if len(tiers) != 1:
+ raise SuperDocError(
+ f'Custom action "{name}" must have exactly one of steps/run '
+ f"(got {'none' if not tiers else ' + '.join(tiers)}).",
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name, 'tiers': tiers},
+ )
+ if isinstance(action.get('steps'), (list, tuple)):
+ steps = action['steps']
+ if len(steps) == 0:
+ raise SuperDocError(
+ f'Custom action "{name}": steps must be non-empty.',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name},
+ )
+ for index, step in enumerate(steps):
+ step_action = step.get('action') if isinstance(step, dict) else None
+ if not isinstance(step_action, str) or step_action not in BUILTIN_ACTION_NAMES:
+ raise SuperDocError(
+ f'Custom action "{name}": steps[{index}].action must be a built-in core action.',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name, 'index': index},
+ )
+ if name in BUILTIN_ACTION_NAMES:
+ raise SuperDocError(
+ f'Custom action "{name}" collides with a built-in core action name. '
+ f'Use a namespaced name like ".{name}".',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name},
+ )
+ if name in _RESERVED_TOOL_NAMES:
+ raise SuperDocError(
+ f'Custom action "{name}" collides with a reserved tool name โ it would shadow the agent surface itself.',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name},
+ )
+ if name in seen:
+ raise SuperDocError(
+ f'Duplicate custom action name "{name}" in preset "{preset_id}".',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name},
+ )
+ # In standalone mode the action name becomes a provider tool name, which
+ # OpenAI/Anthropic reject unless it matches ^[A-Za-z0-9_-]{1,64}$ (dotted
+ # namespaced names are only valid as merged enum VALUES).
+ if standalone and not _PROVIDER_SAFE_TOOL_NAME.match(name or ''):
+ raise SuperDocError(
+ f'standalone action names must match ^[A-Za-z0-9_-]{{1,64}}$; '
+ f'"{name}" has invalid characters โ use merged mode for dotted names.',
+ code='INVALID_ARGUMENT',
+ details={'presetId': preset_id, 'name': name},
+ )
+ seen.add(name)
+
+
+# ---------------------------------------------------------------------------
+# Tool-list merging โ mirror provider shapes from agent/catalog.ts
+# ---------------------------------------------------------------------------
+
+
+def _tool_name(tool: Any) -> str:
+ if not isinstance(tool, dict):
+ return ''
+ fn = tool.get('function')
+ if isinstance(fn, dict) and isinstance(fn.get('name'), str):
+ return fn['name']
+ return tool.get('name', '') if isinstance(tool.get('name'), str) else ''
+
+
+def _custom_actions_description(actions: Sequence[Dict[str, Any]]) -> str:
+ body = '; '.join(f"{r['name']} ({r['description']})" for r in actions)
+ return f' Custom actions: {body}.'
+
+
+def _perform_action_shape(tools: Sequence[Any]) -> tuple:
+ """Read the (description, JSON-schema) of the superdoc_perform_action tool
+ from a get_tools result, across provider shapes (function-wrapped or flat,
+ input_schema/inputSchema/parameters). Returns (None, None) when the tool is
+ absent โ e.g. every built-in excluded, where the host drops it."""
+ for tool in tools:
+ if _tool_name(tool) != 'superdoc_perform_action' or not isinstance(tool, dict):
+ continue
+ fn = tool.get('function') if isinstance(tool.get('function'), dict) else None
+ container = fn if fn is not None else tool
+ schema_key = ('input_schema' if 'input_schema' in container
+ else 'inputSchema' if 'inputSchema' in container else 'parameters')
+ schema = container.get(schema_key)
+ desc = container.get('description') if isinstance(container.get('description'), str) else None
+ return desc, (dict(schema) if isinstance(schema, dict) else None)
+ return None, None
+
+
+_METADATA_SCHEMA_KEYS = {'description', 'title', 'examples', '$comment'}
+
+
+def _structural_schema(value: Any) -> Any:
+ """Drop doc-only keys (recursively) so only the structural shape is compared."""
+ if isinstance(value, dict):
+ return {k: _structural_schema(v) for k, v in value.items() if k not in _METADATA_SCHEMA_KEYS}
+ if isinstance(value, list):
+ return [_structural_schema(v) for v in value]
+ return value
+
+
+def _arg_schemas_conflict(a: Any, b: Any) -> bool:
+ """Two arg schemas CONFLICT when they differ in any way EXCEPT documentation
+ (description/title/examples/$comment). Reusing a built-in arg name with your
+ own description is fine, but a different type, enum (incl. one-sided),
+ default, limit, pattern, or nested shape is a real conflict."""
+ return json.dumps(_structural_schema(a), sort_keys=True) != json.dumps(_structural_schema(b), sort_keys=True)
+
+
+def _assert_no_arg_conflict(properties: Dict[str, Any], key: str, value: Any, action_name: str) -> None:
+ """Reject a custom arg whose name collides with an existing arg (built-in or
+ an earlier custom action) of an INCOMPATIBLE type/enum. A shared name with a
+ compatible type but extra metadata (description/default) is allowed."""
+ if key in properties and _arg_schemas_conflict(properties[key], value):
+ raise SuperDocError(
+ f'Custom action "{action_name}" declares argument "{key}" with a schema that conflicts with an '
+ 'existing argument of the same name on the superdoc_perform_action surface. Rename the argument, '
+ 'or align its type.',
+ code='INVALID_ARGUMENT',
+ details={'action': action_name, 'arg': key},
+ )
+
+
+def _merge_into_superdoc_perform_action(tools: List[Any], actions: Sequence[Dict[str, Any]]) -> List[Any]:
+ out: List[Any] = []
+ for tool in tools:
+ if _tool_name(tool) != 'superdoc_perform_action' or not isinstance(tool, dict):
+ out.append(tool)
+ continue
+ t = dict(tool)
+ fn = t.get('function') if isinstance(t.get('function'), dict) else None
+ container = dict(fn) if fn is not None else t
+ schema_key = ('input_schema' if 'input_schema' in container
+ else 'inputSchema' if 'inputSchema' in container else 'parameters')
+ schema = dict(container.get(schema_key) or {})
+ properties = dict(schema.get('properties') or {})
+ action_prop = dict(properties.get('action') or {})
+ enum_values = list(action_prop.get('enum') or [])
+ for action in actions:
+ if action['name'] not in enum_values:
+ enum_values.append(action['name'])
+ for key, value in (action.get('inputSchema', {}).get('properties') or {}).items():
+ _assert_no_arg_conflict(properties, key, value, action['name'])
+ if key not in properties:
+ properties[key] = value
+ action_prop['enum'] = enum_values
+ properties['action'] = action_prop
+ schema['properties'] = properties
+ base_desc = container.get('description') if isinstance(container.get('description'), str) else ''
+ container['description'] = base_desc + _custom_actions_description(actions)
+ container[schema_key] = schema
+ if fn is not None:
+ t['function'] = container
+ out.append(t)
+ else:
+ out.append(container)
+ return out
+
+
+def _synthesize_perform_action(provider: ToolProvider, actions: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
+ """When the base dropped superdoc_perform_action entirely (all built-ins
+ excluded / an empty allowlist), active custom actions would be advertised
+ in the prompt and dispatchable โ but carried by NO tool. Synthesize a
+ custom-only definition so a curated custom-only preset stays callable."""
+ properties: Dict[str, Any] = {
+ 'action': {'type': 'string', 'enum': [r['name'] for r in actions]},
+ }
+ for action in actions:
+ for key, value in (action.get('inputSchema', {}).get('properties') or {}).items():
+ _assert_no_arg_conflict(properties, key, value, action['name'])
+ properties.setdefault(key, value)
+ schema = {'type': 'object', 'additionalProperties': True, 'required': ['action'], 'properties': properties}
+ description = (
+ "Perform one of this preset's custom document actions. Pick an action and pass its flat arguments."
+ + _custom_actions_description(actions)
+ )
+ if provider == 'anthropic':
+ return {'name': 'superdoc_perform_action', 'description': description, 'input_schema': schema}
+ if provider == 'vercel':
+ return {'name': 'superdoc_perform_action', 'description': description, 'inputSchema': schema}
+ if provider == 'openai':
+ return {'type': 'function',
+ 'function': {'name': 'superdoc_perform_action', 'description': description, 'parameters': schema}}
+ return {'name': 'superdoc_perform_action', 'description': description, 'parameters': schema}
+
+
+def _merge_or_synthesize_perform_action(tools: List[Any], actions: Sequence[Dict[str, Any]],
+ provider: ToolProvider) -> List[Any]:
+ if any(_tool_name(t) == 'superdoc_perform_action' for t in tools):
+ return _merge_into_superdoc_perform_action(tools, actions)
+ return tools + [_synthesize_perform_action(provider, actions)]
+
+
+def _standalone_tool(provider: ToolProvider, action: Dict[str, Any]) -> Dict[str, Any]:
+ if provider == 'anthropic':
+ return {'name': action['name'], 'description': action['description'], 'input_schema': action['inputSchema']}
+ # The core agent dialect for vercel is FLAT {name, description, inputSchema}
+ # (agent/catalog.ts toVercelTool) โ not the OpenAI nested function shape.
+ if provider == 'vercel':
+ return {'name': action['name'], 'description': action['description'], 'inputSchema': action['inputSchema']}
+ if provider == 'openai':
+ return {
+ 'type': 'function',
+ 'function': {
+ 'name': action['name'],
+ 'description': action['description'],
+ 'parameters': action['inputSchema'],
+ },
+ }
+ # generic
+ return {'name': action['name'], 'description': action['description'], 'parameters': action['inputSchema']}
+
+
+def _renormalize_anthropic_cache_marker(
+ tools: List[Any], provider: ToolProvider, cache_requested: bool
+) -> List[Any]:
+ """Re-apply the Anthropic prompt-cache marker after the tool list was
+ mutated. Mirrors Node's ``renormalizeAnthropicCacheMarker``: the base places
+ ``cache_control`` on the LAST tool; appending/removing tools can leave it
+ mid-list or drop it, so strip any existing markers and re-apply to the final
+ last tool. No-op for other providers / when not requested / empty list.
+ """
+ if provider != 'anthropic' or not cache_requested or not tools:
+ return tools
+ stripped: List[Any] = []
+ for tool in tools:
+ if isinstance(tool, dict) and 'cache_control' in tool:
+ stripped.append({k: v for k, v in tool.items() if k != 'cache_control'})
+ else:
+ stripped.append(tool)
+ last = stripped[-1]
+ if isinstance(last, dict):
+ stripped[-1] = {**last, 'cache_control': {'type': 'ephemeral'}}
+ return stripped
+
+
+# ---------------------------------------------------------------------------
+# runCustomAction โ validate, codegen, dispatch via superdoc_execute_code, map receipt
+# ---------------------------------------------------------------------------
+
+
+# Kit-level args every custom action accepts without declaring them.
+_IMPLICIT_ACTION_ARGS = frozenset({'changeMode', 'rationale'})
+
+
+def _validate_against_schema(action: Dict[str, Any], args: Dict[str, Any]) -> None:
+ schema = action.get('inputSchema', {})
+ required = schema.get('required') or []
+ missing = [key for key in required if args.get(key) is None]
+ if missing:
+ raise SuperDocError(
+ f"Missing required argument(s) for {action['name']}: {', '.join(missing)}",
+ code='INVALID_ARGUMENT',
+ details={'action': action['name'], 'missingKeys': missing},
+ )
+ properties = schema.get('properties') if isinstance(schema.get('properties'), dict) else {}
+ if schema.get('additionalProperties') is False:
+ unknown = [k for k in args if k not in properties and k not in _IMPLICIT_ACTION_ARGS]
+ if unknown:
+ raise SuperDocError(
+ f"Unknown argument(s) for {action['name']}: {', '.join(unknown)}",
+ code='INVALID_ARGUMENT',
+ details={'action': action['name'], 'unknownKeys': unknown, 'knownKeys': list(properties)},
+ )
+ for key, prop in properties.items():
+ if key not in args:
+ continue
+ value = args[key]
+ # Validate whenever the key is PRESENT โ an explicit None is a value,
+ # not an absence (Node parity: null !== undefined). Letting None
+ # through would also bypass schema defaults, which only fill missing
+ # keys, so the action would receive a live None.
+ if isinstance(prop, dict) and isinstance(prop.get('enum'), list) and value not in prop['enum']:
+ raise SuperDocError(
+ f"Invalid value for {action['name']}.{key}: {value!r} (allowed: {prop['enum']})",
+ code='INVALID_ARGUMENT',
+ details={'action': action['name'], 'key': key, 'allowed': prop['enum']},
+ )
+
+
+# ---------------------------------------------------------------------------
+# steps tier โ templating + interpreter (identical semantics to Node)
+# ---------------------------------------------------------------------------
+
+_WHOLE_TEMPLATE = re.compile(r'^\{\{(\w+)\}\}$')
+
+
+def _apply_input_defaults(action: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]:
+ out = dict(args)
+ for key, prop in (action.get('inputSchema', {}).get('properties') or {}).items():
+ if key not in out and isinstance(prop, dict) and 'default' in prop:
+ out[key] = prop['default']
+ return out
+
+
+def _substitute_templates(node: Any, arg_values: Dict[str, Any]) -> Any:
+ """Whole-string ``"{{x}}"`` yields the RAW value; partial templates
+ interpolate as text; keys resolving to None-from-missing are dropped by the
+ dict branch (so optional args don't inject nulls into step args)."""
+ if isinstance(node, str):
+ whole = _WHOLE_TEMPLATE.match(node)
+ if whole:
+ return arg_values.get(whole.group(1), _MISSING)
+ def _text(value: Any) -> str:
+ # Byte-identical with Node: strings verbatim, None -> '', everything
+ # else compact JSON (true/[1,2]/{"a":1}) โ NOT str(), whose bool/
+ # list/dict forms differ from JS String().
+ if value is None:
+ return ''
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, separators=(',', ':'), ensure_ascii=False)
+ return re.sub(r'\{\{(\w+)\}\}', lambda m: _text(arg_values.get(m.group(1))), node)
+ if isinstance(node, list):
+ # Whole-string templates for ABSENT args are dropped from arrays too โ
+ # the sentinel must never leak into a transport payload.
+ return [item for item in (_substitute_templates(entry, arg_values) for entry in node) if item is not _MISSING]
+ if isinstance(node, dict):
+ out = {}
+ for key, value in node.items():
+ substituted = _substitute_templates(value, arg_values)
+ if substituted is not _MISSING:
+ out[key] = substituted
+ return out
+ return node
+
+
+class _MissingType:
+ def __repr__(self) -> str: # pragma: no cover
+ return ''
+
+
+_MISSING = _MissingType()
+
+
+def _step_args_for(action: Dict[str, Any], step: Dict[str, Any], arg_values: Dict[str, Any]) -> Dict[str, Any]:
+ step_args = _substitute_templates(step.get('args') or {}, arg_values)
+ if step_args is _MISSING or not isinstance(step_args, dict):
+ step_args = {}
+ # changeMode pass-through: a caller-level changeMode reaches every step
+ # that doesn't pin its own.
+ if isinstance(arg_values.get('changeMode'), str) and 'changeMode' not in step_args:
+ step_args = {**step_args, 'changeMode': arg_values['changeMode']}
+ return step_args
+
+
+def _steps_row(index: int, step: Dict[str, Any], receipt: Dict[str, Any]) -> Dict[str, Any]:
+ row = {'step': index, 'action': step['action'], 'status': receipt.get('status')}
+ if 'verificationPassed' in receipt:
+ row['verificationPassed'] = receipt['verificationPassed']
+ return row
+
+
+def _steps_aggregate(action: Dict[str, Any], rows: List[Dict[str, Any]], failed_at: Optional[int] = None,
+ failed_receipt: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ if failed_at is None:
+ return {'status': 'succeeded', 'action': action['name'], 'steps': rows}
+ any_landed = any(row['status'] != 'failed' for row in rows)
+ return {
+ 'status': 'partial' if any_landed else 'failed',
+ 'action': action['name'],
+ 'steps': rows,
+ 'failedStep': {'index': failed_at, 'receipt': failed_receipt},
+ }
+
+
+def _run_steps_action(base: Any, action: Dict[str, Any], document_handle: Any,
+ args: Dict[str, Any], invoke_options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ arg_values = args # defaults already applied by the router
+ rows: List[Dict[str, Any]] = []
+ for index, step in enumerate(action['steps']):
+ step_args = _step_args_for(action, step, arg_values)
+ try:
+ # Steps are the author's curated composition, not model calls โ so
+ # surface exclude_actions (already enforced at the top-level
+ # dispatch) must not refuse a built-in this action composes.
+ dispatched = base.dispatch(
+ document_handle, 'superdoc_perform_action', {'action': step['action'], **step_args},
+ invoke_options
+ )
+ receipt = dispatched if isinstance(dispatched, dict) else {'status': 'ok', 'result': dispatched}
+ except Exception as error: # noqa: BLE001 โ validation errors throw; normalize
+ receipt = {'status': 'failed', 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}]}
+ rows.append(_steps_row(index, step, receipt))
+ if receipt.get('status') == 'failed':
+ return _steps_aggregate(action, rows, index, receipt)
+ # Truthfulness: a partially-landed step (or failed verification) must
+ # not roll up into a clean succeeded โ stop and report partial.
+ if receipt.get('status') == 'partial' or receipt.get('verificationPassed') is False:
+ return {
+ 'status': 'partial',
+ 'action': action['name'],
+ 'steps': rows,
+ 'failedStep': {'index': index, 'receipt': receipt},
+ }
+ return _steps_aggregate(action, rows)
+
+
+async def _run_steps_action_async(base: Any, action: Dict[str, Any], document_handle: Any,
+ args: Dict[str, Any], invoke_options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ arg_values = args # defaults already applied by the router
+ rows: List[Dict[str, Any]] = []
+ for index, step in enumerate(action['steps']):
+ step_args = _step_args_for(action, step, arg_values)
+ try:
+ # Steps are the author's curated composition, not model calls โ so
+ # surface exclude_actions (already enforced at the top-level
+ # dispatch) must not refuse a built-in this action composes.
+ dispatched = await base.dispatch_async(
+ document_handle, 'superdoc_perform_action', {'action': step['action'], **step_args},
+ invoke_options
+ )
+ receipt = dispatched if isinstance(dispatched, dict) else {'status': 'ok', 'result': dispatched}
+ except Exception as error: # noqa: BLE001
+ receipt = {'status': 'failed', 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}]}
+ rows.append(_steps_row(index, step, receipt))
+ if receipt.get('status') == 'failed':
+ return _steps_aggregate(action, rows, index, receipt)
+ # Truthfulness: a partially-landed step (or failed verification) must
+ # not roll up into a clean succeeded โ stop and report partial.
+ if receipt.get('status') == 'partial' or receipt.get('verificationPassed') is False:
+ return {
+ 'status': 'partial',
+ 'action': action['name'],
+ 'steps': rows,
+ 'failedStep': {'index': index, 'receipt': receipt},
+ }
+ return _steps_aggregate(action, rows)
+
+
+# ---------------------------------------------------------------------------
+# run tier โ native Python callable with a synthesized truth-telling receipt
+# ---------------------------------------------------------------------------
+
+
+def _read_revision(document_handle: Any) -> Optional[str]:
+ info = getattr(document_handle, 'info', None)
+ if not callable(info):
+ return None
+ try:
+ result = info({})
+ revision = result.get('revision') if isinstance(result, dict) else None
+ return None if revision is None else str(revision)
+ except Exception: # noqa: BLE001 โ revision evidence is best-effort
+ return None
+
+
+def _native_receipt(action: Dict[str, Any], pre: Optional[str], post: Optional[str],
+ result: Any = None, error: Optional[Exception] = None) -> Dict[str, Any]:
+ if error is None:
+ return {'status': 'succeeded', 'action': action['name'], 'result': result,
+ 'preRevision': pre, 'postRevision': post}
+ partial = pre is not None and post is not None and pre != post
+ return {
+ 'status': 'failed',
+ 'action': action['name'],
+ 'errors': [{'code': getattr(error, 'code', None), 'message': str(error)}],
+ 'preRevision': pre,
+ 'postRevision': post,
+ 'partialMutation': partial,
+ 'recovery': {'kind': 'revert', 'call': 'superdoc_perform_action {action:"undo_changes"}'}
+ if partial else {'kind': 'retry'},
+ }
+
+
+def _run_native_action(action: Dict[str, Any], document_handle: Any, args: Dict[str, Any]) -> Dict[str, Any]:
+ arg_values = args # defaults already applied by the router
+ pre = _read_revision(document_handle)
+ try:
+ result = action['run'](document_handle, arg_values)
+ if inspect.isawaitable(result):
+ # An async run function through the SYNC dispatcher would return an
+ # un-awaited coroutine as a "successful" result โ refuse loudly.
+ result.close()
+ raise SuperDocError(
+ f"{action['name']}: run returned an awaitable from the sync dispatcher โ "
+ 'use dispatch_superdoc_tool_async / dispatch_async for async run functions.',
+ code='INVALID_ARGUMENT',
+ details={'action': action['name']},
+ )
+ return _native_receipt(action, pre, _read_revision(document_handle), result=result)
+ except Exception as error: # noqa: BLE001 โ becomes a failed receipt
+ return _native_receipt(action, pre, _read_revision(document_handle), error=error)
+
+
+async def _read_revision_async(document_handle: Any) -> Optional[str]:
+ info = getattr(document_handle, 'info', None)
+ if not callable(info):
+ return None
+ try:
+ result = info({})
+ if inspect.isawaitable(result):
+ result = await result
+ revision = result.get('revision') if isinstance(result, dict) else None
+ return None if revision is None else str(revision)
+ except Exception: # noqa: BLE001 โ revision evidence is best-effort
+ return None
+
+
+async def _run_native_action_async(action: Dict[str, Any], document_handle: Any, args: Dict[str, Any]) -> Dict[str, Any]:
+ arg_values = args # defaults already applied by the router
+ pre = await _read_revision_async(document_handle)
+ try:
+ result = action['run'](document_handle, arg_values)
+ if inspect.isawaitable(result):
+ result = await result
+ return _native_receipt(action, pre, await _read_revision_async(document_handle), result=result)
+ except Exception as error: # noqa: BLE001
+ return _native_receipt(action, pre, await _read_revision_async(document_handle), error=error)
+
+
+# ---------------------------------------------------------------------------
+# tier router โ one entry point for both preset wrappers
+# ---------------------------------------------------------------------------
+
+
+def _run_custom_action(base: Any, action: Dict[str, Any], document_handle: Any,
+ raw_args: Optional[Dict[str, Any]], invoke_options: Optional[Dict[str, Any]],
+ from_perform_action: bool = True) -> Dict[str, Any]:
+ args = dict(raw_args or {})
+ # `action` is the superdoc_perform_action discriminator ONLY on that route;
+ # in standalone mode it may be a real declared argument โ strip only when
+ # it came in as the discriminator.
+ if from_perform_action:
+ args.pop('action', None)
+ # Apply schema defaults BEFORE validation โ a required arg with a declared
+ # default is satisfiable by the default.
+ args = _apply_input_defaults(action, args)
+ _validate_against_schema(action, args)
+ kind = execution_kind_of(action)
+ if kind == 'steps':
+ return _run_steps_action(base, action, document_handle, args, invoke_options)
+ return _run_native_action(action, document_handle, args)
+
+
+async def _run_custom_action_async(base: Any, action: Dict[str, Any], document_handle: Any,
+ raw_args: Optional[Dict[str, Any]],
+ invoke_options: Optional[Dict[str, Any]],
+ from_perform_action: bool = True) -> Dict[str, Any]:
+ args = dict(raw_args or {})
+ if from_perform_action:
+ args.pop('action', None)
+ args = _apply_input_defaults(action, args)
+ _validate_against_schema(action, args)
+ kind = execution_kind_of(action)
+ if kind == 'steps':
+ return await _run_steps_action_async(base, action, document_handle, args, invoke_options)
+ return await _run_native_action_async(action, document_handle, args)
+
+
+def _split_exclusions(by_name: dict, exclude_actions: Optional[list]) -> tuple:
+ """excludeActions may name BUILT-IN actions (forwarded to the base, which
+ validates them) or CUSTOM actions (handled by the wrapper โ the base would
+ reject names it doesn't know). Returns (custom_excluded_set, builtin_list_or_None)."""
+ if not exclude_actions:
+ return frozenset(), None
+ custom = {name for name in exclude_actions if name in by_name}
+ builtin = [name for name in exclude_actions if name not in by_name]
+ return frozenset(custom), (builtin or None)
+
+
+# ---------------------------------------------------------------------------
+# extend_preset โ wrap a base preset with custom actions
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class _ExtendedPreset:
+ id: str
+ description: str
+ supports_cache_control: bool
+ _base: Any
+ _actions: tuple
+ _by_name: dict
+ _standalone: bool
+ _system_prompt_extra: Optional[str] = None
+
+ # --- tool surface ---
+ def get_tools(self, provider: ToolProvider, *, cache: bool = False,
+ exclude_actions: Optional[list] = None) -> Dict[str, Any]:
+ custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions)
+ base_kwargs = {'exclude_actions': builtin_excluded} if builtin_excluded else {}
+ result = self._base.get_tools(provider, cache=cache, **base_kwargs)
+ active = [r for r in self._actions if r['name'] not in custom_excluded]
+ if not active:
+ return result
+ tools = list(result.get('tools') or [])
+ if self._standalone:
+ tools = tools + [_standalone_tool(provider, r) for r in active]
+ else:
+ tools = _merge_or_synthesize_perform_action(tools, active, provider)
+ tools = _renormalize_anthropic_cache_marker(
+ tools, provider, bool(cache) and result.get('cacheStrategy') != 'disabled')
+ return {**result, 'tools': tools}
+
+ def get_catalog(self) -> Dict[str, Any]:
+ catalog = self._base.get_catalog()
+ rows = list(catalog.get('tools') or [])
+ rows = rows + [
+ {
+ 'toolName': r['name'],
+ 'description': r['description'],
+ 'inputSchema': r['inputSchema'],
+ 'mutates': True,
+ 'operations': [],
+ }
+ for r in self._actions
+ ]
+ return {**catalog, 'toolCount': len(rows), 'tools': rows}
+
+ def _prompt_extra(self, custom_excluded: frozenset = frozenset()) -> str:
+ if self._system_prompt_extra is not None:
+ return self._system_prompt_extra
+ active = [r for r in self._actions if r['name'] not in custom_excluded]
+ if not active:
+ return ''
+ bullets = '\n'.join(f"- {r['name']} โ {r['description']}" for r in active)
+ return f'\n\n## Custom actions\n{bullets}'
+
+ def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str:
+ custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions)
+ base_kwargs = {'exclude_actions': builtin_excluded} if builtin_excluded else {}
+ return self._base.get_system_prompt(**base_kwargs) + self._prompt_extra(custom_excluded)
+
+ def get_mcp_prompt(self) -> str:
+ return self._base.get_mcp_prompt() + self._prompt_extra()
+
+ # --- dispatch ---
+ def dispatch(
+ self,
+ document_handle: Any,
+ tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None,
+ *,
+ exclude_actions: Optional[list] = None,
+ ) -> Any:
+ action = self._resolve_custom(tool_name, args)
+ if action is not None:
+ # Defense-in-depth parity with core: an excluded CUSTOM action is
+ # refused before it runs (the base can only refuse built-ins).
+ if exclude_actions and action['name'] in exclude_actions:
+ raise SuperDocError(
+ f"Action {action['name']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': action['name'], 'excluded': True},
+ )
+ return _run_custom_action(self._base, action, document_handle, args, invoke_options,
+ from_perform_action=tool_name == 'superdoc_perform_action')
+ return self._base.dispatch(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions)
+
+ async def dispatch_async(
+ self,
+ document_handle: Any,
+ tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None,
+ *,
+ exclude_actions: Optional[list] = None,
+ ) -> Any:
+ action = self._resolve_custom(tool_name, args)
+ if action is not None:
+ if exclude_actions and action['name'] in exclude_actions:
+ raise SuperDocError(
+ f"Action {action['name']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': action['name'], 'excluded': True},
+ )
+ return await _run_custom_action_async(self._base, action, document_handle, args, invoke_options,
+ from_perform_action=tool_name == 'superdoc_perform_action')
+ return await self._base.dispatch_async(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions)
+
+ def _resolve_custom(self, tool_name: str, args: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ # Advertised surface == dispatchable surface: in standalone mode custom
+ # actions are advertised as their own tools, so the (unadvertised)
+ # perform_action route must not execute them โ the call falls through
+ # to the base, which rejects the unknown action name.
+ if (not self._standalone and tool_name == 'superdoc_perform_action'
+ and isinstance(args, dict) and isinstance(args.get('action'), str)):
+ action = self._by_name.get(args['action'])
+ if action is not None:
+ return action
+ if self._standalone and tool_name in self._by_name:
+ return self._by_name[tool_name]
+ return None
+
+
+def extend_preset(
+ base_id: str,
+ id: str,
+ actions: Sequence[Dict[str, Any]],
+ system_prompt_extra: Optional[str] = None,
+ standalone: bool = False,
+ description: Optional[str] = None,
+) -> _ExtendedPreset:
+ """Wrap ``get_preset(base_id)`` with custom actions (Python mirror of
+ Node's ``extendPreset``)."""
+ base = get_preset(base_id)
+ action_list = list(actions or [])
+ _assert_actions_valid(action_list, id, bool(standalone))
+ return _ExtendedPreset(
+ id=id,
+ description=description or f'{base.description} + {len(action_list)} custom action(s).',
+ supports_cache_control=getattr(base, 'supports_cache_control', True),
+ _base=base,
+ _actions=tuple(action_list),
+ _by_name={r['name']: r for r in action_list},
+ _standalone=bool(standalone),
+ _system_prompt_extra=system_prompt_extra,
+ )
+
+
+# ---------------------------------------------------------------------------
+# compose_preset โ build a preset over core, filtering its surface
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class _ComposedPreset:
+ id: str
+ description: str
+ supports_cache_control: bool
+ _base: Any
+ _base_id: str
+ _actions: tuple
+ _by_name: dict
+ _include_core: Optional[frozenset]
+ _drop_superdoc_execute_code: bool
+ _system_prompt: Optional[str]
+
+ def get_tools(self, provider: ToolProvider, *, cache: bool = False,
+ exclude_actions: Optional[list] = None) -> Dict[str, Any]:
+ custom_excluded, builtin_excluded = _split_exclusions(self._by_name, exclude_actions)
+ # The allowlist is implemented as a DERIVED exclusion forwarded to the
+ # base, so core narrows the enum, the grouped description, AND the
+ # advertised argument properties natively โ no hand-rebuilt schemas
+ # here (a second implementation of that narrowing is what drifts).
+ derived = sorted(BUILTIN_ACTION_NAMES - self._include_core) if self._include_core is not None else []
+ merged_excludes = sorted({*derived, *(builtin_excluded or [])})
+ base_kwargs = {'exclude_actions': merged_excludes} if merged_excludes else {}
+ result = self._base.get_tools(provider, cache=cache, **base_kwargs)
+ tools = list(result.get('tools') or [])
+ if self._drop_superdoc_execute_code:
+ tools = [t for t in tools if _tool_name(t) != 'superdoc_execute_code']
+ active = [r for r in self._actions if r['name'] not in custom_excluded]
+ if active:
+ tools = _merge_or_synthesize_perform_action(tools, active, provider)
+ # Dropping superdoc_execute_code can strip the marker off the (former) last tool;
+ # re-normalize so the anthropic cached prefix is still correct.
+ tools = _renormalize_anthropic_cache_marker(
+ tools, provider, bool(cache) and result.get('cacheStrategy') != 'disabled')
+ return {**result, 'tools': tools}
+
+ def get_catalog(self) -> Dict[str, Any]:
+ catalog = self._base.get_catalog()
+ rows = list(catalog.get('tools') or [])
+ if self._drop_superdoc_execute_code:
+ rows = [r for r in rows if r.get('toolName') != 'superdoc_execute_code']
+ # Advertised == dispatchable, catalog included: when include_core
+ # narrows the surface, the catalog's superdoc_perform_action row must
+ # narrow WITH it โ enum, description, AND argument properties โ or
+ # get_tool_catalog() still advertises inputs for actions the preset
+ # refuses. Source the narrowed row from the base's own get_tools (the
+ # same host builder that narrows get_tools natively), so there is one
+ # narrowing implementation, not a second that drifts.
+ if self._include_core is not None:
+ derived = sorted(BUILTIN_ACTION_NAMES - self._include_core)
+ narrowed_tools = (self._base.get_tools('anthropic', exclude_actions=derived) or {}).get('tools') or []
+ desc, schema = _perform_action_shape(narrowed_tools)
+ updated = []
+ for row in rows:
+ if row.get('toolName') != 'superdoc_perform_action':
+ updated.append(row)
+ continue
+ # No built-in tool from the host means every built-in was
+ # excluded; drop the row rather than keep advertising built-ins
+ # (custom actions still appear as their own rows below).
+ if schema is None:
+ continue
+ row = {**row, 'inputSchema': schema}
+ if desc is not None:
+ row['description'] = desc
+ updated.append(row)
+ rows = updated
+ rows = rows + [
+ {
+ 'toolName': r['name'],
+ 'description': r['description'],
+ 'inputSchema': r['inputSchema'],
+ 'mutates': True,
+ 'operations': [],
+ }
+ for r in self._actions
+ ]
+ return {**catalog, 'toolCount': len(rows), 'tools': rows}
+
+ def _prompt(self, base_prompt: str, custom_excluded: frozenset = frozenset()) -> str:
+ if self._system_prompt is not None:
+ return self._system_prompt
+ active = [r for r in self._actions if r['name'] not in custom_excluded]
+ if not active:
+ return base_prompt
+ bullets = '\n'.join(f"- {r['name']} โ {r['description']}" for r in active)
+ return base_prompt + f'\n\n## Custom actions\n{bullets}'
+
+ def get_system_prompt(self, *, exclude_actions: Optional[list] = None) -> str:
+ if self._system_prompt is not None:
+ return self._system_prompt
+ # include_core narrows the ENUM; the prompt must narrow WITH it โ a
+ # per-action manual for an uncallable action teaches the model to call it.
+ custom_excluded, builtin_requested = _split_exclusions(self._by_name, exclude_actions)
+ derived = sorted(BUILTIN_ACTION_NAMES - self._include_core) if self._include_core is not None else []
+ merged = sorted({*derived, *(builtin_requested or [])})
+ base_kwargs = {'exclude_actions': merged} if merged else {}
+ return self._prompt(self._base.get_system_prompt(**base_kwargs), custom_excluded)
+
+ def get_mcp_prompt(self) -> str:
+ if self._system_prompt is not None:
+ return self._system_prompt
+ return self._prompt(self._base.get_mcp_prompt())
+
+ def dispatch(
+ self,
+ document_handle: Any,
+ tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None,
+ *,
+ exclude_actions: Optional[list] = None,
+ ) -> Any:
+ if tool_name == 'superdoc_perform_action' and isinstance(args, dict) and isinstance(args.get('action'), str):
+ action = self._by_name.get(args['action'])
+ if action is not None:
+ if exclude_actions and action['name'] in exclude_actions:
+ raise SuperDocError(
+ f"Action {action['name']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': action['name'], 'excluded': True},
+ )
+ return _run_custom_action(self._base, action, document_handle, args, invoke_options)
+ # Composition allowlist is a dispatch boundary too: a built-in
+ # outside include_core_actions is not advertised and must not
+ # execute on a guessed/stale call.
+ if (self._include_core is not None and args['action'] in BUILTIN_ACTION_NAMES
+ and args['action'] not in self._include_core):
+ raise SuperDocError(
+ f"Action {args['action']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': args['action'], 'excluded': True,
+ 'excludedBy': 'includeCoreActions'},
+ )
+ return self._base.dispatch(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions)
+
+ async def dispatch_async(
+ self,
+ document_handle: Any,
+ tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None,
+ *,
+ exclude_actions: Optional[list] = None,
+ ) -> Any:
+ if tool_name == 'superdoc_perform_action' and isinstance(args, dict) and isinstance(args.get('action'), str):
+ action = self._by_name.get(args['action'])
+ if action is not None:
+ if exclude_actions and action['name'] in exclude_actions:
+ raise SuperDocError(
+ f"Action {action['name']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': action['name'], 'excluded': True},
+ )
+ return await _run_custom_action_async(self._base, action, document_handle, args, invoke_options)
+ if (self._include_core is not None and args['action'] in BUILTIN_ACTION_NAMES
+ and args['action'] not in self._include_core):
+ raise SuperDocError(
+ f"Action {args['action']} is excluded by configuration.",
+ code='INVALID_ARGUMENT',
+ details={'toolName': tool_name, 'action': args['action'], 'excluded': True,
+ 'excludedBy': 'includeCoreActions'},
+ )
+ return await self._base.dispatch_async(document_handle, tool_name, args, invoke_options, exclude_actions=exclude_actions)
+
+
+def compose_preset(
+ id: str,
+ base_id: str = 'core',
+ include_core_actions: Optional[Sequence[str]] = None,
+ include_superdoc_execute_code: Optional[bool] = None,
+ actions: Optional[Sequence[Dict[str, Any]]] = None,
+ system_prompt: Optional[str] = None,
+ description: Optional[str] = None,
+) -> _ComposedPreset:
+ """Compose a preset over ``core`` (Python mirror of Node's
+ ``composePreset``)."""
+ action_list = list(actions or [])
+ _assert_actions_valid(action_list, id)
+ if include_core_actions is not None:
+ for name in include_core_actions:
+ if name not in BUILTIN_ACTION_NAMES:
+ raise SuperDocError(
+ f'includeCoreActions: unknown action "{name}".',
+ code='INVALID_ARGUMENT',
+ details={'presetId': id, 'unknownAction': name},
+ )
+ base = get_preset(base_id)
+ return _ComposedPreset(
+ id=id,
+ description=description or f'Composed over {base_id} with {len(action_list)} custom action(s).',
+ supports_cache_control=getattr(base, 'supports_cache_control', True),
+ _base=base,
+ _base_id=base_id,
+ _actions=tuple(action_list),
+ _by_name={r['name']: r for r in action_list},
+ _include_core=frozenset(include_core_actions) if include_core_actions is not None else None,
+ _drop_superdoc_execute_code=(include_superdoc_execute_code is False),
+ _system_prompt=system_prompt,
+ )
diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py
index 3c5d999b55..45f111a292 100644
--- a/packages/sdk/langs/python/superdoc/tools_api.py
+++ b/packages/sdk/langs/python/superdoc/tools_api.py
@@ -185,6 +185,59 @@ def create_agent_toolkit(
preset = preset_arg if preset_arg is not None else DEFAULT_PRESET
exclude_actions = list(input.get('excludeActions') or input.get('exclude_actions') or []) or None
+ # One-call custom-actions path: hand your actions to the toolkit and use it.
+ # Build an ephemeral extended/composed preset over `base` (default 'core')
+ # and drive it directly โ no register_preset, no preset id to thread through
+ # dispatch. Advanced callers can still extend_preset/compose_preset + preset.
+ actions = input.get('actions') or []
+ include_core = input.get('includeCoreActions')
+ if include_core is None:
+ include_core = input.get('include_core_actions')
+ if actions or include_core is not None:
+ from .presets.custom import extend_preset, compose_preset # lazy: avoid import cycle
+ provider = input.get('provider')
+ # Validate provider up front (parity with choose_tools) โ the actions
+ # path builds tools directly, so it must not skip this check.
+ if provider not in ('openai', 'anthropic', 'vercel', 'generic'):
+ raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider})
+ # `preset` doubles as the base to extend here; `base` wins if both given.
+ base_arg = input.get('base')
+ base_id = base_arg if base_arg is not None else (preset_arg if preset_arg is not None else 'core')
+ if include_core is not None:
+ descriptor = compose_preset(id='custom_superdoc_preset', base_id=base_id,
+ include_core_actions=include_core, actions=actions)
+ else:
+ descriptor = extend_preset(base_id, id='custom_superdoc_preset', actions=actions)
+ tools_res = descriptor.get_tools(provider, cache=bool(input.get('cache')),
+ exclude_actions=exclude_actions)
+ tools = tools_res.get('tools') if isinstance(tools_res.get('tools'), list) else []
+ sys_prompt = descriptor.get_system_prompt(exclude_actions=exclude_actions)
+
+ def _dispatch(document_handle: Any, tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None) -> Any:
+ return descriptor.dispatch(document_handle, tool_name, args, invoke_options,
+ exclude_actions=exclude_actions)
+
+ async def _dispatch_async(document_handle: Any, tool_name: str,
+ args: Optional[Dict[str, Any]] = None,
+ invoke_options: Optional[Dict[str, Any]] = None) -> Any:
+ return await descriptor.dispatch_async(document_handle, tool_name, args, invoke_options,
+ exclude_actions=exclude_actions)
+
+ return {
+ 'tools': tools,
+ 'meta': {
+ 'provider': provider,
+ 'preset': descriptor.id,
+ 'toolCount': len(tools),
+ 'cacheStrategy': tools_res.get('cacheStrategy', 'disabled'),
+ },
+ 'system_prompt': sys_prompt,
+ 'dispatch': _dispatch,
+ 'dispatch_async': _dispatch_async,
+ }
+
chosen = choose_tools({**input, 'preset': preset})
system_prompt = (
get_system_prompt(preset, exclude_actions=exclude_actions)
diff --git a/packages/sdk/langs/python/tests/footnote_fixture.py b/packages/sdk/langs/python/tests/footnote_fixture.py
new file mode 100644
index 0000000000..e1adba0298
--- /dev/null
+++ b/packages/sdk/langs/python/tests/footnote_fixture.py
@@ -0,0 +1,126 @@
+"""Footnote actions โ the shared test fixture for custom actions (Python).
+
+Five namespaced ``run``-tier custom actions over the typed ``doc.footnotes.*``
+Document API. They run in the caller's process against the session-bound
+handle โ the tier a customer reaches for when the built-in actions don't cover
+their domain.
+
+Test fixture only: it is NOT part of the shipped package and lives under
+``tests/`` so it never ships in the wheel. Both the unit tests (against a fake
+base) and the smoke test (against the real CLI host) import it.
+"""
+
+from typing import Any, Dict
+
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from superdoc.presets.custom import define_action # noqa: E402
+
+
+def _add(doc: Any, args: Dict[str, Any]) -> Any:
+ return doc.footnotes.insert({
+ 'at': args['at'],
+ 'type': args.get('type', 'footnote'),
+ 'content': args['content'],
+ })
+
+
+def _list(doc: Any, args: Dict[str, Any]) -> Any:
+ return doc.footnotes.list({'type': args['type']} if args.get('type') else {})
+
+
+def _edit(doc: Any, args: Dict[str, Any]) -> Any:
+ return doc.footnotes.update({
+ 'target': {'kind': 'entity', 'entityType': 'footnote', 'noteId': args['noteId']},
+ 'patch': {'content': args['content']},
+ })
+
+
+def _remove(doc: Any, args: Dict[str, Any]) -> Any:
+ return doc.footnotes.remove({
+ 'target': {'kind': 'entity', 'entityType': 'footnote', 'noteId': args['noteId']},
+ })
+
+
+def _renumber(doc: Any, args: Dict[str, Any]) -> Any:
+ numbering: Dict[str, Any] = {}
+ if args.get('format'):
+ numbering['format'] = args['format']
+ if args.get('start') is not None:
+ numbering['start'] = args['start']
+ return doc.footnotes.configure({
+ 'type': args.get('type', 'footnote'),
+ 'scope': {'kind': 'document'},
+ 'numbering': numbering,
+ })
+
+
+footnote_add = define_action(
+ name='footnotes.add',
+ description=(
+ "Insert a footnote (or endnote) at a text target. args: { at: TextTarget "
+ "{kind:'text',segments:[{blockId,range:{start,end}}]}, content: string, type?: 'footnote'|'endnote' }."
+ ),
+ input_schema={
+ 'type': 'object', 'additionalProperties': False, 'required': ['at', 'content'],
+ 'properties': {
+ 'at': {'type': 'object'},
+ 'content': {'type': 'string'},
+ 'type': {'type': 'string', 'enum': ['footnote', 'endnote']},
+ },
+ },
+ run=_add,
+)
+
+footnote_list = define_action(
+ name='footnotes.list',
+ description="List footnotes (or endnotes). args: { type?: 'footnote'|'endnote' }.",
+ input_schema={
+ 'type': 'object', 'additionalProperties': False,
+ 'properties': {'type': {'type': 'string', 'enum': ['footnote', 'endnote']}},
+ },
+ run=_list,
+)
+
+footnote_edit = define_action(
+ name='footnotes.edit',
+ description="Edit a footnote's content by noteId. args: { noteId: string, content: string }.",
+ input_schema={
+ 'type': 'object', 'additionalProperties': False, 'required': ['noteId', 'content'],
+ 'properties': {'noteId': {'type': 'string'}, 'content': {'type': 'string'}},
+ },
+ run=_edit,
+)
+
+footnote_remove = define_action(
+ name='footnotes.remove',
+ description='Remove a footnote by noteId. args: { noteId: string }.',
+ input_schema={
+ 'type': 'object', 'additionalProperties': False, 'required': ['noteId'],
+ 'properties': {'noteId': {'type': 'string'}},
+ },
+ run=_remove,
+)
+
+footnote_renumber = define_action(
+ name='footnotes.renumber',
+ description=(
+ "Reconfigure footnote numbering for the whole document. "
+ "args: { type?: 'footnote'|'endnote', format?: string, start?: number }."
+ ),
+ input_schema={
+ 'type': 'object', 'additionalProperties': False,
+ 'properties': {
+ 'type': {'type': 'string', 'enum': ['footnote', 'endnote']},
+ 'format': {'type': 'string',
+ 'enum': ['decimal', 'lowerRoman', 'upperRoman', 'lowerLetter', 'upperLetter', 'symbol']},
+ 'start': {'type': 'number'},
+ },
+ },
+ run=_renumber,
+)
+
+footnote_actions = [footnote_add, footnote_list, footnote_edit, footnote_remove, footnote_renumber]
diff --git a/packages/sdk/langs/python/tests/smoke_custom_actions.py b/packages/sdk/langs/python/tests/smoke_custom_actions.py
new file mode 100644
index 0000000000..be3e114faf
--- /dev/null
+++ b/packages/sdk/langs/python/tests/smoke_custom_actions.py
@@ -0,0 +1,133 @@
+"""Smoke test for Python custom actions (footnotes) over the `core` preset.
+
+Mirrors ``smoke_core_preset.py``. Verifies the cross-language path:
+
+ 1. ``register_preset(extend_preset('core', id='acme', actions=footnote_actions))``
+ makes ``acme`` resolvable.
+ 2. ``choose_tools({preset:'acme', provider:'anthropic'})`` shows the footnote
+ action names in the ``superdoc_perform_action`` enum.
+ 3. ``dispatch_superdoc_tool(doc, 'superdoc_perform_action', {action:'footnotes.add', ...},
+ preset='acme')`` runs the run-tier action against the host; ``footnotes.list``
+ then shows the inserted note.
+
+Run with ``SUPERDOC_CLI_BIN=/abs/path/to/apps/cli/dist/index.js`` if the
+companion-CLI package is not installed in the local interpreter.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any, Dict
+
+HERE = Path(__file__).resolve().parent
+SDK_ROOT = HERE.parent
+sys.path.insert(0, str(SDK_ROOT))
+
+from superdoc import ( # noqa: E402
+ SuperDocClient,
+ choose_tools,
+ dispatch_superdoc_tool,
+ list_presets,
+ register_preset,
+ unregister_preset,
+)
+from superdoc.presets.custom import extend_preset # noqa: E402
+from footnote_fixture import footnote_actions # noqa: E402
+
+REPO_ROOT = HERE.parents[4]
+# Same fixture the Node custom-actions e2e test uses.
+FIXTURE_PATH = str(
+ REPO_ROOT / 'packages' / 'super-editor' / 'src' / 'editors' / 'v1' / 'tests' / 'data' / 'advanced-text.docx'
+)
+
+
+def _section(title: str) -> None:
+ print()
+ print(f'== {title} ==')
+
+
+def _assert(condition: bool, message: str) -> None:
+ if not condition:
+ print(f'FAIL: {message}')
+ sys.exit(1)
+ print(f' ok โ {message}')
+
+
+def main() -> Dict[str, Any]:
+ fixture = Path(FIXTURE_PATH)
+ if not fixture.exists():
+ print(f'FAIL: fixture missing at {fixture}')
+ sys.exit(1)
+
+ # ---- register acme ---------------------------------------------------
+ register_preset(extend_preset('core', id='acme', actions=footnote_actions))
+ try:
+ _assert('acme' in list_presets(), "list_presets() includes 'acme'")
+
+ # ---- choose_tools ------------------------------------------------
+ _section('choose_tools({preset:acme, provider:anthropic})')
+ chooser = choose_tools({'provider': 'anthropic', 'preset': 'acme'})
+ action_tool = next((t for t in chooser['tools'] if t.get('name') == 'superdoc_perform_action'), None)
+ _assert(action_tool is not None, 'superdoc_perform_action tool present')
+ names = action_tool['input_schema']['properties']['action']['enum']
+ for r in footnote_actions:
+ _assert(r['name'] in names, f"{r['name']} in superdoc_perform_action enum")
+
+ # ---- dispatch footnotes.add + footnotes.list ---------------------
+ with tempfile.TemporaryDirectory() as tmp:
+ working_copy = Path(tmp) / 'fixture.docx'
+ shutil.copy2(fixture, working_copy)
+
+ env_overrides: Dict[str, str] = {}
+ cli_bin_env = os.environ.get('SUPERDOC_CLI_BIN')
+ if not cli_bin_env:
+ default_dev_cli = str(SDK_ROOT.parents[3] / 'apps/cli/dist/index.js')
+ if Path(default_dev_cli).exists():
+ env_overrides['SUPERDOC_CLI_BIN'] = default_dev_cli
+
+ with SuperDocClient(env=env_overrides or None) as client:
+ doc = client.open({'doc': str(working_copy)})
+
+ blocks = doc.blocks.list({'limit': 50, 'includeText': True})
+ para = next(
+ (b for b in blocks['blocks'] if b['nodeType'] == 'paragraph' and (b.get('text') or '')),
+ None,
+ )
+ _assert(para is not None, 'found a non-empty body paragraph to anchor on')
+ at = {'kind': 'text', 'segments': [{'blockId': para['nodeId'], 'range': {'start': 0, 'end': 0}}]}
+
+ _section('dispatch footnotes.add (preset=acme)')
+ add = dispatch_superdoc_tool(
+ doc,
+ 'superdoc_perform_action',
+ {'action': 'footnotes.add', 'at': at, 'content': 'Inserted by footnotes.add (Python).'},
+ preset='acme',
+ )
+ print(f' receipt status: {add.get("status")}, action: {add.get("action")}')
+ _assert(add.get('action') == 'footnotes.add', 'receipt action is footnotes.add')
+ _assert(add.get('status') == 'succeeded', 'footnotes.add succeeded')
+
+ _section('dispatch footnotes.list (preset=acme)')
+ listed = dispatch_superdoc_tool(
+ doc, 'superdoc_perform_action', {'action': 'footnotes.list'}, preset='acme'
+ )
+ items = (listed.get('result') or {}).get('items') or []
+ print(f' footnotes listed: {len(items)}')
+ _assert(listed.get('status') == 'succeeded', 'footnotes.list succeeded')
+ _assert(len(items) >= 1, 'at least one footnote present after add')
+
+ doc.close({'discard': True})
+ finally:
+ unregister_preset('acme')
+
+ print()
+ print('SMOKE PASSED.')
+ return {'mutation_observed': True}
+
+
+if __name__ == '__main__':
+ main()
diff --git a/packages/sdk/langs/python/tests/test_custom_actions.py b/packages/sdk/langs/python/tests/test_custom_actions.py
new file mode 100644
index 0000000000..a4de09a078
--- /dev/null
+++ b/packages/sdk/langs/python/tests/test_custom_actions.py
@@ -0,0 +1,1060 @@
+"""Custom-action unit tests (Python) โ no CLI host required.
+
+Mirrors the host-free portion of the Node ``custom-actions.test.ts``: codegen
+template, tool merging, collision/duplicate checks, registry register/unregister,
+and the superdoc_execute_code rewrite + receipt mapping (against a fake base preset).
+"""
+
+import os
+import re
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from superdoc import ( # noqa: E402
+ SuperDocError,
+ get_preset,
+ register_preset,
+ unregister_preset,
+)
+from superdoc.presets import _BUILTIN_IDS # noqa: E402
+from superdoc.presets.custom import ( # noqa: E402
+ BUILTIN_ACTION_NAMES,
+ compose_preset,
+ define_action,
+ extend_preset,
+)
+from footnote_fixture import footnote_actions # noqa: E402
+
+
+def _cli_available() -> bool:
+ if os.environ.get('SUPERDOC_CLI_BIN'):
+ return True
+ try:
+ from superdoc.embedded_cli import resolve_embedded_cli_path
+
+ resolve_embedded_cli_path()
+ return True
+ except Exception: # noqa: BLE001 โ no binary on this platform/CI stage
+ return False
+
+
+# The python core preset proxies get_tools/dispatch through the CLI host, so
+# tests exercising the REAL core preset need a binary. They run locally and in
+# host-equipped CI stages; the pure-registry/fake-base tests cover the logic
+# everywhere else (Node runs the same scenarios natively).
+requires_host = pytest.mark.skipif(not _cli_available(), reason='SuperDoc CLI binary unavailable')
+
+
+@pytest.fixture
+def cleanup_registered():
+ ids = []
+ yield ids
+ for pid in ids:
+ try:
+ unregister_preset(pid)
+ except SuperDocError:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# define_action โ two tiers
+# ---------------------------------------------------------------------------
+
+
+def test_define_action_run_tier_keeps_callable():
+ fn = lambda doc, args: args # noqa: E731
+ spec = define_action(
+ name='demo.echo',
+ description='echo',
+ input_schema={'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': ['x']},
+ run=fn,
+ )
+ assert spec['name'] == 'demo.echo'
+ assert spec['run'] is fn
+ assert 'source' not in spec
+ assert spec['inputSchema']['required'] == ['x']
+
+
+def test_define_action_steps_tier_validates_builtins():
+ spec = define_action(
+ name='demo.steps', description='d',
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}],
+ )
+ assert spec['steps'] == [{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}]
+ with pytest.raises(SuperDocError):
+ define_action(name='demo.bad', description='d', steps=[{'action': 'not_a_real_action'}])
+
+
+def test_define_action_requires_exactly_one_tier():
+ with pytest.raises(SuperDocError):
+ define_action(name='x.none', description='d')
+ with pytest.raises(SuperDocError):
+ define_action(name='x.two', description='d', run=lambda d, a: None,
+ steps=[{'action': 'insert_paragraphs'}])
+
+
+# ---------------------------------------------------------------------------
+# collision / duplicate
+# ---------------------------------------------------------------------------
+
+
+def test_rejects_collision_with_builtin():
+ builtin = next(iter(BUILTIN_ACTION_NAMES))
+ bad = define_action(name=builtin, description='x', run=lambda d, a: None)
+ with pytest.raises(SuperDocError) as ex:
+ extend_preset('core', id='bad', actions=[bad])
+ assert ex.value.code == 'INVALID_ARGUMENT'
+
+
+def test_rejects_duplicate_names():
+ a = define_action(name='dup.one', description='a', run=lambda d, a: None)
+ b = define_action(name='dup.one', description='b', run=lambda d, a: None)
+ with pytest.raises(SuperDocError):
+ extend_preset('core', id='dup', actions=[a, b])
+
+
+# The canonical 40 names from ACTION_NAMES_LIST in
+# node/src/agent/actions.ts (source of truth). Asserting the EXACT set โ not
+# just the count โ makes any drift from Node fail loudly.
+_CANONICAL_ACTION_NAMES = sorted([
+ 'accept_tracked_changes',
+ 'add_comments',
+ 'add_hyperlink',
+ 'add_list_items',
+ 'append_list',
+ 'apply_letter_spacing',
+ 'apply_style',
+ 'attach_numbering',
+ 'comment_paragraphs',
+ 'convert_list',
+ 'create_table',
+ 'delete_table',
+ 'delete_table_column',
+ 'delete_table_row',
+ 'delete_text',
+ 'fill_placeholders',
+ 'format_paragraph',
+ 'format_text',
+ 'insert_heading',
+ 'insert_page_break',
+ 'insert_paragraphs',
+ 'insert_table_column',
+ 'insert_table_row',
+ 'insert_toc',
+ 'move_range',
+ 'move_table',
+ 'move_text',
+ 'normalize_body_font_size',
+ 'redo_changes',
+ 'reject_tracked_changes',
+ 'reply_to_comment',
+ 'resolve_comments',
+ 'replace_text',
+ 'rewrite_block',
+ 'set_font_family',
+ 'set_paragraph_spacing',
+ 'split_list',
+ 'split_table',
+ 'style_table',
+ 'undo_changes',
+])
+
+
+def test_builtin_action_names_match_node_exactly():
+ assert sorted(BUILTIN_ACTION_NAMES) == _CANONICAL_ACTION_NAMES
+
+
+def test_builtin_action_names_match_node_source():
+ """The real drift guard: parse Node's ACTION_NAMES (the source of truth)
+ and compare the exact set. The hand-copied list above is only a fallback
+ for environments without the Node source tree."""
+ node_actions = (
+ Path(__file__).resolve().parents[2] / 'node' / 'src' / 'agent' / 'actions.ts'
+ )
+ if not node_actions.exists():
+ pytest.skip('Node source tree not available (installed-package run)')
+ source = node_actions.read_text()
+ match = re.search(r'const ACTION_NAMES: readonly ActionName\[\] = \[(.*?)\];', source, re.S)
+ assert match, 'ACTION_NAMES declaration not found in node actions.ts'
+ node_names = set(re.findall(r"'([a-z_]+)'", match.group(1)))
+ assert node_names == set(BUILTIN_ACTION_NAMES), (
+ f'drift: only-in-node={sorted(node_names - BUILTIN_ACTION_NAMES)} '
+ f'only-in-python={sorted(BUILTIN_ACTION_NAMES - node_names)}'
+ )
+
+
+# ---------------------------------------------------------------------------
+# registry
+# ---------------------------------------------------------------------------
+
+
+def test_register_resolve_unregister(cleanup_registered):
+ p = extend_preset('core', id='tmp-preset', actions=footnote_actions)
+ register_preset(p)
+ cleanup_registered.append('tmp-preset')
+ assert get_preset('tmp-preset').id == 'tmp-preset'
+ unregister_preset('tmp-preset')
+ with pytest.raises(SuperDocError):
+ get_preset('tmp-preset')
+
+
+def test_cannot_overwrite_builtin():
+ p = extend_preset('core', id='will-rename', actions=[])
+ object.__setattr__(p, 'id', 'core') # frozen dataclass
+ with pytest.raises(SuperDocError):
+ register_preset(p)
+
+
+def test_cannot_unregister_builtin():
+ for pid in _BUILTIN_IDS:
+ with pytest.raises(SuperDocError):
+ unregister_preset(pid)
+
+
+# ---------------------------------------------------------------------------
+# tool merging (against a fake base โ no host)
+# ---------------------------------------------------------------------------
+
+
+class _FakeBase:
+ """A minimal core-shaped base preset that records superdoc_execute_code calls."""
+
+ id = 'fake-base'
+ description = 'fake'
+ supports_cache_control = True
+
+ def __init__(self):
+ self.captured = {}
+
+ def get_tools(self, provider, *, cache=False, exclude_actions=None):
+ # core-shaped superdoc_perform_action with an enum + flat properties.
+ action_tool = {
+ 'name': 'superdoc_perform_action',
+ 'description': 'base action tool.',
+ 'input_schema': {
+ 'type': 'object',
+ 'properties': {
+ 'action': {'type': 'string', 'enum': ['insert_paragraphs']},
+ 'text': {'type': 'string'},
+ },
+ },
+ }
+ execute = {'name': 'superdoc_execute_code', 'input_schema': {'type': 'object', 'properties': {}}}
+ tools = [action_tool, execute]
+ # Mirror core's marker placement so the wrapper's re-normalize path is
+ # exercised: anthropic cache marks the LAST tool.
+ if cache and provider == 'anthropic':
+ tools[-1] = {**tools[-1], 'cache_control': {'type': 'ephemeral'}}
+ return {'tools': tools, 'cacheStrategy': 'explicit'}
+ return {'tools': tools, 'cacheStrategy': 'disabled'}
+
+ def get_catalog(self):
+ return {
+ 'contractVersion': 'fake',
+ 'generatedAt': None,
+ 'toolCount': 1,
+ 'tools': [{'toolName': 'superdoc_execute_code', 'description': 'x', 'inputSchema': {}, 'mutates': True, 'operations': []}],
+ }
+
+ def get_system_prompt(self, *, exclude_actions=None):
+ return 'base prompt'
+
+ def get_mcp_prompt(self):
+ return 'base mcp'
+
+ def dispatch(self, handle, tool_name, args=None, invoke_options=None):
+ assert tool_name == 'superdoc_execute_code'
+ self.captured['code'] = (args or {}).get('code')
+ return {'ok': True, 'result': {'success': True, 'footnote': {'noteId': 'fn-1'}}, 'logs': []}
+
+
+def _register_fake(cleanup_registered):
+ fake = _FakeBase()
+ register_preset(fake)
+ cleanup_registered.append('fake-base')
+ return fake
+
+
+def test_extend_merges_into_superdoc_perform_action(cleanup_registered):
+ _register_fake(cleanup_registered)
+ acme = extend_preset('fake-base', id='acme-merge', actions=footnote_actions)
+ register_preset(acme)
+ cleanup_registered.append('acme-merge')
+ tools = acme.get_tools('anthropic')['tools']
+ action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ names = action_tool['input_schema']['properties']['action']['enum']
+ for r in footnote_actions:
+ assert r['name'] in names
+ assert 'insert_paragraphs' in names # base preserved
+ assert 'noteId' in action_tool['input_schema']['properties'] # unioned
+
+
+def _safe_actions():
+ """Footnote actions with provider-safe (dot-free) names for standalone mode."""
+ return [{**r, 'name': r['name'].replace('.', '_')} for r in footnote_actions]
+
+
+def test_extend_standalone_tools(cleanup_registered):
+ _register_fake(cleanup_registered)
+ safe = _safe_actions()
+ acme = extend_preset('fake-base', id='acme-standalone', actions=safe, standalone=True)
+ register_preset(acme)
+ cleanup_registered.append('acme-standalone')
+ tools = acme.get_tools('openai')['tools']
+ names = [t.get('function', t).get('name') for t in tools]
+ for r in safe:
+ assert r['name'] in names
+
+
+def test_standalone_vercel_uses_flat_core_dialect(cleanup_registered):
+ _register_fake(cleanup_registered)
+ safe = _safe_actions()
+ acme = extend_preset('fake-base', id='acme-vercel', actions=safe, standalone=True)
+ register_preset(acme)
+ cleanup_registered.append('acme-vercel')
+ tools = acme.get_tools('vercel')['tools']
+ add = next(t for t in tools if t.get('name') == 'footnotes_add')
+ # Core's vercel dialect is flat {name, description, inputSchema}.
+ assert 'function' not in add and 'type' not in add
+ assert add['inputSchema'] is not None
+
+
+@requires_host
+def test_merged_vercel_advertises_customs_in_flat_dialect(cleanup_registered):
+ acme = extend_preset('core', id='acme-vercel-merged', actions=footnote_actions)
+ register_preset(acme)
+ cleanup_registered.append('acme-vercel-merged')
+ tools = acme.get_tools('vercel')['tools']
+ perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ assert 'footnotes.add' in perform['inputSchema']['properties']['action']['enum']
+
+
+@requires_host
+def test_exclude_actions_coherent_on_extended_preset(cleanup_registered):
+ from superdoc import choose_tools as _choose
+
+ acme = extend_preset('core', id='acme-excl', actions=footnote_actions)
+ register_preset(acme)
+ cleanup_registered.append('acme-excl')
+ exclude = ['footnotes.add', 'create_table']
+ tools = _choose({'provider': 'openai', 'preset': 'acme-excl', 'excludeActions': exclude})['tools']
+ perform = next(t['function'] for t in tools if t['function']['name'] == 'superdoc_perform_action')
+ names = perform['parameters']['properties']['action']['enum']
+ assert 'footnotes.add' not in names # custom excluded by the wrapper
+ assert 'create_table' not in names # builtin excluded by the base
+ assert 'footnotes.list' in names # other customs survive
+
+ prompt = acme.get_system_prompt(exclude_actions=exclude)
+ assert '- footnotes.add โ' not in prompt
+ assert 'footnotes.list' in prompt
+
+ with pytest.raises(SuperDocError) as ex:
+ acme.dispatch(object(), 'superdoc_perform_action',
+ {'action': 'footnotes.add', 'at': {}, 'content': 'x'},
+ exclude_actions=exclude)
+ assert ex.value.details.get('excluded') is True
+
+
+def test_steps_partial_step_aggregates_partial(cleanup_registered):
+ _, acme = _register_steps(cleanup_registered, [{'status': 'ok'}, {'status': 'partial'}])
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'})
+ assert receipt['status'] == 'partial'
+ assert receipt['failedStep']['index'] == 1
+
+
+def test_steps_template_text_parity_for_non_strings(cleanup_registered):
+ base = _StepsBase([{'status': 'ok'}])
+ register_preset(base)
+ cleanup_registered.append('steps-base')
+ spec = define_action(
+ name='acme.parity',
+ description='d',
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['flag={{flag}} items={{items}} obj={{obj}}']}}],
+ )
+ acme = extend_preset('steps-base', id='acme-parity', actions=[spec])
+ register_preset(acme)
+ cleanup_registered.append('acme-parity')
+ acme.dispatch(object(), 'superdoc_perform_action',
+ {'action': 'acme.parity', 'flag': True, 'items': [1, 2], 'obj': {'a': 1}})
+ # MUST match Node: JSON text forms, not Python str() forms.
+ assert base.calls[0]['texts'] == ['flag=true items=[1,2] obj={"a":1}']
+
+
+def test_steps_missing_template_dropped_from_arrays(cleanup_registered):
+ base = _StepsBase([{'status': 'ok'}])
+ register_preset(base)
+ cleanup_registered.append('steps-base')
+ spec = define_action(
+ name='acme.arrmiss',
+ description='d',
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{present}}', '{{absent}}']}}],
+ )
+ acme = extend_preset('steps-base', id='acme-arrmiss', actions=[spec])
+ register_preset(acme)
+ cleanup_registered.append('acme-arrmiss')
+ acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.arrmiss', 'present': 'AAA'})
+ assert base.calls[0]['texts'] == ['AAA']
+
+
+def test_raw_spec_with_empty_steps_rejected():
+ raw = {'name': 'acme.empty', 'description': 'd', 'inputSchema': {'type': 'object', 'properties': {}},
+ 'steps': []}
+ with pytest.raises(SuperDocError):
+ extend_preset('core', id='acme-empty', actions=[raw])
+
+
+def test_sync_dispatch_of_async_run_is_refused(cleanup_registered):
+ _register_fake(cleanup_registered)
+
+ async def _async_run(doc, args):
+ return {'never': 'awaited'}
+
+ native = define_action(name='acme.async-native', description='d', run=_async_run)
+ acme = extend_preset('fake-base', id='acme-async-native', actions=[native])
+ register_preset(acme)
+ cleanup_registered.append('acme-async-native')
+ receipt = acme.dispatch(_RevisionHandle(['0', '0']), 'superdoc_perform_action', {'action': 'acme.async-native'})
+ assert receipt['status'] == 'failed'
+ assert 'dispatch_async' in receipt['errors'][0]['message']
+
+
+def test_standalone_does_not_route_customs_through_perform_action(cleanup_registered):
+ fake = _register_fake(cleanup_registered)
+
+ def _spy_dispatch(handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None):
+ return {'delegated': tool_name, 'action': (args or {}).get('action')}
+
+ fake.dispatch = _spy_dispatch # type: ignore[assignment]
+ safe = _safe_actions()
+ acme = extend_preset('fake-base', id='acme-standalone-gate', actions=safe, standalone=True)
+ register_preset(acme)
+ cleanup_registered.append('acme-standalone-gate')
+ # perform_action route must fall through to the base (delegated), not run the custom.
+ result = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'footnotes_add', 'at': {}, 'content': 'x'})
+ assert result == {'delegated': 'superdoc_perform_action', 'action': 'footnotes_add'}
+
+
+def test_standalone_keeps_action_named_argument(cleanup_registered):
+ received = {}
+
+ def _run(doc, args):
+ received.update(args)
+ return {'ok': True}
+
+ spec = define_action(
+ name='wf_trigger', description='run a workflow step',
+ input_schema={'type': 'object', 'properties': {'action': {'type': 'string', 'enum': ['approve', 'reject']}},
+ 'required': ['action']},
+ run=_run,
+ )
+ acme = extend_preset('core', id='acme-standalone-arg', actions=[spec], standalone=True)
+ register_preset(acme)
+ cleanup_registered.append('acme-standalone-arg')
+ # Dispatched as its OWN tool: `action` is a real arg, must survive.
+ receipt = acme.dispatch(object(), 'wf_trigger', {'action': 'approve'})
+ assert receipt['status'] == 'succeeded'
+ assert received == {'action': 'approve'}
+
+
+def test_standalone_rejects_dotted_names_merged_accepts():
+ # Dotted name invalid as a provider tool name (standalone), valid as enum
+ # value (merged).
+ with pytest.raises(SuperDocError) as ex:
+ extend_preset('core', id='bad-standalone', actions=footnote_actions, standalone=True)
+ assert ex.value.code == 'INVALID_ARGUMENT'
+ # merged mode (default) accepts the dotted names without raising.
+ extend_preset('core', id='ok-merged', actions=footnote_actions)
+
+
+def test_anthropic_cache_marks_exactly_last_tool(cleanup_registered):
+ _register_fake(cleanup_registered)
+ safe = _safe_actions()
+ acme = extend_preset('fake-base', id='acme-cache', actions=safe, standalone=True)
+ register_preset(acme)
+ cleanup_registered.append('acme-cache')
+ tools = acme.get_tools('anthropic', cache=True)['tools']
+ with_marker = [t for t in tools if isinstance(t, dict) and t.get('cache_control') is not None]
+ assert len(with_marker) == 1
+ assert tools[-1]['cache_control'] == {'type': 'ephemeral'}
+
+
+def test_dispatch_validates_required(cleanup_registered):
+ _register_fake(cleanup_registered)
+ acme = extend_preset('fake-base', id='acme-validate', actions=footnote_actions)
+ register_preset(acme)
+ cleanup_registered.append('acme-validate')
+ with pytest.raises(SuperDocError) as ex:
+ acme.dispatch(object(), 'superdoc_perform_action', {'action': 'footnotes.add', 'content': 'x'})
+ assert ex.value.code == 'INVALID_ARGUMENT'
+
+
+def test_dispatch_delegates_non_custom(cleanup_registered):
+ fake = _register_fake(cleanup_registered)
+
+ delegated = {}
+
+ def _spy_dispatch(handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None):
+ delegated['tool'] = tool_name
+ return {'delegated': tool_name}
+
+ fake.dispatch = _spy_dispatch # type: ignore[assignment]
+ acme = extend_preset('fake-base', id='acme-delegate', actions=footnote_actions)
+ register_preset(acme)
+ cleanup_registered.append('acme-delegate')
+ result = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'insert_paragraphs', 'text': 'hi'})
+ assert delegated['tool'] == 'superdoc_perform_action'
+ assert result == {'delegated': 'superdoc_perform_action'}
+
+
+# ---------------------------------------------------------------------------
+# compose_preset
+# ---------------------------------------------------------------------------
+
+
+def test_compose_drops_superdoc_execute_code(cleanup_registered):
+ _register_fake(cleanup_registered)
+ composed = compose_preset(id='composed-1', base_id='fake-base', include_superdoc_execute_code=False, actions=footnote_actions)
+ register_preset(composed)
+ cleanup_registered.append('composed-1')
+ tools = composed.get_tools('generic')['tools']
+ names = [t.get('function', t).get('name') for t in tools]
+ assert 'superdoc_execute_code' not in names
+ action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ assert 'footnotes.add' in action_tool['parameters' if 'parameters' in action_tool else 'input_schema']['properties']['action']['enum']
+
+
+@requires_host
+def test_compose_custom_only_synthesizes_perform_action(cleanup_registered):
+ composed = compose_preset(
+ id='composed-custom-only', base_id='core',
+ include_core_actions=[], actions=footnote_actions,
+ )
+ register_preset(composed)
+ cleanup_registered.append('composed-custom-only')
+ tools = composed.get_tools('openai')['tools']
+ perform = next((t for t in tools if (t.get('function') or t).get('name') == 'superdoc_perform_action'), None)
+ assert perform is not None # synthesized โ the base dropped it
+ names = perform['function']['parameters']['properties']['action']['enum']
+ for r in footnote_actions:
+ assert r['name'] in names
+ assert 'insert_paragraphs' not in names
+
+
+@requires_host
+def test_compose_catalog_narrowed_by_allowlist(cleanup_registered):
+ from superdoc import get_tool_catalog
+
+ composed = compose_preset(
+ id='composed-cat', base_id='core',
+ include_core_actions=['insert_paragraphs'], actions=footnote_actions,
+ )
+ register_preset(composed)
+ cleanup_registered.append('composed-cat')
+ catalog = get_tool_catalog('composed-cat')
+ perform = next(t for t in catalog['tools'] if t['toolName'] == 'superdoc_perform_action')
+ props = perform['inputSchema']['properties']
+ names = props['action']['enum']
+ assert 'insert_paragraphs' in names
+ assert 'create_table' not in names # outside the allowlist โ not in the catalog
+ # The row narrows BEYOND the enum: create_table-only args are gone, so a
+ # catalog-driven UI/validator can't offer inputs for a refused action.
+ assert 'rows' not in props and 'columns' not in props
+ assert 'text' in props # insert_paragraphs' own arg survives
+ assert 'create_table' not in perform.get('description', '') # description narrows too
+ # custom actions appear as their own catalog rows (not merged into the
+ # perform_action enum the way get_tools does)
+ assert any(t['toolName'] == 'footnotes.add' for t in catalog['tools'])
+
+
+@requires_host
+def test_compose_catalog_row_matches_get_tools_without_custom(cleanup_registered):
+ from superdoc import choose_tools, get_tool_catalog
+
+ composed = compose_preset(id='composed-cat-nocustom', base_id='core',
+ include_core_actions=['insert_paragraphs'])
+ register_preset(composed)
+ cleanup_registered.append('composed-cat-nocustom')
+ row = next(t for t in get_tool_catalog('composed-cat-nocustom')['tools']
+ if t['toolName'] == 'superdoc_perform_action')
+ # With no custom actions the catalog row and the advertised tool coincide โ
+ # one narrowing, no drift.
+ tools = choose_tools({'provider': 'generic', 'preset': 'composed-cat-nocustom'})['tools']
+ advertised = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ assert row['inputSchema']['properties']['action']['enum'] == advertised['parameters']['properties']['action']['enum']
+ assert sorted(row['inputSchema']['properties']) == sorted(advertised['parameters']['properties'])
+
+
+@requires_host
+def test_compose_allowlist_no_enum_leak_and_dispatch_enforced(cleanup_registered):
+ composed = compose_preset(
+ id='composed-leak', base_id='core',
+ include_core_actions=['insert_paragraphs'], actions=footnote_actions,
+ )
+ register_preset(composed)
+ cleanup_registered.append('composed-leak')
+ tools = composed.get_tools('generic', exclude_actions=['footnotes.add'])['tools']
+ perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ names = perform['parameters']['properties']['action']['enum']
+ assert 'footnotes.add' not in names # must not leak back via the allowlist rebuild
+ assert 'footnotes.list' in names
+ assert 'insert_paragraphs' in names
+ # The DESCRIPTION and advertised args narrow with the allowlist too โ the
+ # base rebuilds them from the derived exclusion, not a hand-rolled filter.
+ assert 'create_table' not in perform['description']
+ # allowlist enforced at dispatch too
+ with pytest.raises(SuperDocError) as ex:
+ composed.dispatch(object(), 'superdoc_perform_action', {'action': 'create_table', 'rows': 1, 'columns': 1})
+ assert ex.value.details.get('excludedBy') == 'includeCoreActions'
+
+
+def test_reserved_tool_names_match_node_catalog():
+ node_catalog = (
+ Path(__file__).resolve().parents[2] / 'node' / 'src' / 'agent' / 'catalog.ts'
+ )
+ if not node_catalog.exists():
+ pytest.skip('Node source tree not available (installed-package run)')
+ source = node_catalog.read_text()
+ match = re.search(r'export const AGENT_TOOL_NAMES = \[(.*?)\] as const;', source, re.S)
+ assert match, 'AGENT_TOOL_NAMES declaration not found in node catalog.ts'
+ node_names = set(re.findall(r"'([a-z_]+)'", match.group(1)))
+ from superdoc.presets.custom import _RESERVED_TOOL_NAMES
+
+ assert node_names == set(_RESERVED_TOOL_NAMES), (
+ f'drift: only-in-node={sorted(node_names - _RESERVED_TOOL_NAMES)} '
+ f'only-in-python={sorted(set(_RESERVED_TOOL_NAMES) - node_names)}'
+ )
+
+
+def test_explicit_none_enum_value_rejected(cleanup_registered):
+ base = _StepsBase([{'status': 'ok'}])
+ register_preset(base)
+ cleanup_registered.append('steps-base')
+ spec = define_action(
+ name='acme.enumnull', description='d',
+ input_schema={'type': 'object', 'properties': {
+ 'mode': {'type': 'string', 'enum': ['a', 'b'], 'default': 'a'}}},
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['x']}}],
+ )
+ acme = extend_preset('steps-base', id='acme-enumnull', actions=[spec])
+ register_preset(acme)
+ cleanup_registered.append('acme-enumnull')
+ # Explicit None is a VALUE, not an absence โ reject (Node parity), don't
+ # silently forward a live None past the default.
+ with pytest.raises(SuperDocError) as ex:
+ acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.enumnull', 'mode': None})
+ assert ex.value.code == 'INVALID_ARGUMENT'
+ # Omitting the key entirely still applies the default and succeeds.
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.enumnull'})
+ assert receipt['status'] == 'succeeded'
+
+
+def test_reserved_tool_name_rejected():
+ bad = define_action(name='superdoc_execute_code', description='x', run=lambda d, a: None)
+ with pytest.raises(SuperDocError):
+ extend_preset('core', id='bad-reserved', actions=[bad], standalone=True)
+
+
+def test_required_with_default_satisfied_by_default(cleanup_registered):
+ base = _StepsBase([{'status': 'ok'}])
+ register_preset(base)
+ cleanup_registered.append('steps-base')
+ spec = define_action(
+ name='acme.reqdef', description='d',
+ input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'D'}},
+ 'required': ['label']},
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}],
+ )
+ acme = extend_preset('steps-base', id='acme-reqdef', actions=[spec])
+ register_preset(acme)
+ cleanup_registered.append('acme-reqdef')
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.reqdef'})
+ assert receipt['status'] == 'succeeded'
+ assert base.calls[0]['texts'] == ['D']
+
+
+@requires_host
+def test_compose_exclude_actions_coherent(cleanup_registered):
+ from superdoc import choose_tools as _choose
+
+ composed = compose_preset(id='composed-excl', base_id='core', actions=footnote_actions)
+ register_preset(composed)
+ cleanup_registered.append('composed-excl')
+ exclude = ['footnotes.add', 'create_table']
+ tools = _choose({'provider': 'generic', 'preset': 'composed-excl', 'excludeActions': exclude})['tools']
+ perform = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ names = perform['parameters']['properties']['action']['enum']
+ assert 'footnotes.add' not in names
+ assert 'create_table' not in names
+ assert 'footnotes.list' in names
+ prompt = composed.get_system_prompt(exclude_actions=exclude)
+ assert '- footnotes.add โ' not in prompt
+
+
+def test_compose_filters_core_actions(cleanup_registered):
+ _register_fake(cleanup_registered)
+ composed = compose_preset(
+ id='composed-2',
+ base_id='fake-base',
+ include_core_actions=['insert_paragraphs'],
+ actions=footnote_actions,
+ )
+ register_preset(composed)
+ cleanup_registered.append('composed-2')
+ tools = composed.get_tools('anthropic')['tools']
+ action_tool = next(t for t in tools if t.get('name') == 'superdoc_perform_action')
+ names = action_tool['input_schema']['properties']['action']['enum']
+ assert 'insert_paragraphs' in names
+ assert 'footnotes.add' in names
+
+
+# ---------------------------------------------------------------------------
+# steps tier โ declarative composition (mirrors Node's steps-tier tests)
+# ---------------------------------------------------------------------------
+
+
+class _StepsBase:
+ """Fake base recording superdoc_perform_action step dispatches with scripted receipts."""
+
+ id = 'steps-base'
+ description = 'fake'
+ supports_cache_control = True
+
+ def __init__(self, receipts):
+ self.calls = []
+ self._receipts = receipts
+ self._cursor = 0
+
+ def get_tools(self, provider, *, cache=False):
+ return {'tools': [], 'cacheStrategy': 'disabled'}
+
+ def get_catalog(self):
+ return {'contractVersion': 'x', 'generatedAt': None, 'toolCount': 0, 'tools': []}
+
+ def get_system_prompt(self):
+ return 'base'
+
+ def get_mcp_prompt(self):
+ return 'base'
+
+ def dispatch(self, handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None):
+ assert tool_name == 'superdoc_perform_action'
+ self.calls.append(args)
+ item = self._receipts[min(self._cursor, len(self._receipts) - 1)]
+ self._cursor += 1
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+
+def _stamp_spec():
+ return define_action(
+ name='acme.stamp',
+ description='banner + comment',
+ input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'CONFIDENTIAL'}}},
+ steps=[
+ {'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}},
+ {'action': 'add_comments', 'args': {
+ 'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}']}],
+ 'commentText': 'Stamped: {{label}} โ verify.',
+ }},
+ ],
+ )
+
+
+def _register_steps(cleanup_registered, receipts):
+ base = _StepsBase(receipts)
+ register_preset(base)
+ cleanup_registered.append('steps-base')
+ acme = extend_preset('steps-base', id='acme-steps', actions=[_stamp_spec()])
+ register_preset(acme)
+ cleanup_registered.append('acme-steps')
+ return base, acme
+
+
+def test_steps_templating_defaults_and_success(cleanup_registered):
+ base, acme = _register_steps(cleanup_registered, [{'status': 'ok', 'verificationPassed': True}])
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'})
+ assert receipt['status'] == 'succeeded'
+ assert len(receipt['steps']) == 2
+ # Whole-string template preserved the raw list value; default applied.
+ assert base.calls[0] == {
+ 'action': 'insert_paragraphs',
+ 'texts': ['CONFIDENTIAL'],
+ 'placement': {'at': 'document_start'},
+ }
+ # Partial template interpolated as text.
+ assert base.calls[1]['commentText'] == 'Stamped: CONFIDENTIAL โ verify.'
+ assert base.calls[1]['selectors'] == [{'kind': 'textSearch', 'terms': ['CONFIDENTIAL']}]
+
+
+def test_steps_change_mode_passthrough(cleanup_registered):
+ base, acme = _register_steps(cleanup_registered, [{'status': 'ok'}])
+ acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp', 'label': 'X', 'changeMode': 'tracked'})
+ assert base.calls[0]['changeMode'] == 'tracked'
+ assert base.calls[1]['changeMode'] == 'tracked'
+
+
+def test_steps_do_not_inherit_surface_exclusions(cleanup_registered):
+ # Base that mirrors core's defense-in-depth: refuse any call whose action is
+ # in the invoke-time exclude_actions. If a surface exclusion leaked into the
+ # steps, the insert_paragraphs step would be refused and acme.stamp
+ # (advertised!) would fail โ the coherence bug this guards against.
+ seen = []
+
+ class _ExclEnforcingBase:
+ id = 'steps-excl-base'
+ description = 'fake base enforcing invoke-time exclusions'
+ supports_cache_control = True
+
+ def get_tools(self, provider, *, cache=False, exclude_actions=None):
+ return {'tools': [], 'cacheStrategy': 'disabled'}
+
+ def get_catalog(self):
+ return {'contractVersion': 'x', 'generatedAt': None, 'toolCount': 0, 'tools': []}
+
+ def get_system_prompt(self, *, exclude_actions=None):
+ return 'base'
+
+ def get_mcp_prompt(self):
+ return 'base'
+
+ def dispatch(self, handle, tool_name, args=None, invoke_options=None, *, exclude_actions=None):
+ seen.append(exclude_actions)
+ action = (args or {}).get('action')
+ if exclude_actions and action in exclude_actions:
+ raise SuperDocError(f"Action {action} is excluded by configuration.",
+ code='INVALID_ARGUMENT', details={'excluded': True})
+ return {'status': 'ok', 'verificationPassed': True}
+
+ register_preset(_ExclEnforcingBase())
+ cleanup_registered.append('steps-excl-base')
+ acme = extend_preset('steps-excl-base', id='acme-steps-excl', actions=[_stamp_spec()])
+ register_preset(acme)
+ cleanup_registered.append('acme-steps-excl')
+
+ # Hide insert_paragraphs from the model, but acme.stamp composes it.
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'},
+ exclude_actions=['insert_paragraphs'])
+ assert receipt['status'] == 'succeeded' # step NOT refused
+ assert len(receipt['steps']) == 2
+ assert all(s is None for s in seen) # internal steps saw NO exclude_actions
+
+ # Guard preserved: a DIRECT model call to the hidden built-in is still refused.
+ with pytest.raises(SuperDocError):
+ acme.dispatch(object(), 'superdoc_perform_action', {'action': 'insert_paragraphs', 'text': 'x'},
+ exclude_actions=['insert_paragraphs'])
+
+
+def test_steps_second_failure_aggregates_partial(cleanup_registered):
+ _, acme = _register_steps(
+ cleanup_registered,
+ [{'status': 'ok'}, {'status': 'failed', 'errors': [{'message': 'no match'}]}],
+ )
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'})
+ assert receipt['status'] == 'partial'
+ assert receipt['failedStep']['index'] == 1
+
+
+def test_steps_thrown_first_step_aggregates_failed(cleanup_registered):
+ _, acme = _register_steps(cleanup_registered, [SuperDocError('bad args', code='INVALID_ARGUMENT')])
+ receipt = acme.dispatch(object(), 'superdoc_perform_action', {'action': 'acme.stamp'})
+ assert receipt['status'] == 'failed'
+ assert receipt['steps'][0]['status'] == 'failed'
+
+
+def test_steps_reject_unknown_builtin_at_define_time():
+ with pytest.raises(SuperDocError) as ex:
+ define_action(name='demo.bad', description='d', steps=[{'action': 'not_a_real_action'}])
+ assert ex.value.code == 'INVALID_ARGUMENT'
+
+
+# ---------------------------------------------------------------------------
+# run tier โ native Python callable with a synthesized receipt
+# ---------------------------------------------------------------------------
+
+
+class _RevisionHandle:
+ def __init__(self, revisions):
+ self._revisions = revisions
+ self._i = 0
+
+ def info(self, params):
+ revision = self._revisions[min(self._i, len(self._revisions) - 1)]
+ self._i += 1
+ return {'revision': revision}
+
+
+def test_run_tier_success_receipt(cleanup_registered):
+ _register_fake(cleanup_registered)
+ native = define_action(
+ name='acme.native',
+ description='d',
+ input_schema={'type': 'object', 'properties': {'x': {'type': 'number', 'default': 7}}},
+ run=lambda doc, args: {'got': args['x']},
+ )
+ acme = extend_preset('fake-base', id='acme-native', actions=[native])
+ register_preset(acme)
+ cleanup_registered.append('acme-native')
+ receipt = acme.dispatch(_RevisionHandle(['0', '1']), 'superdoc_perform_action', {'action': 'acme.native'})
+ assert receipt['status'] == 'succeeded'
+ assert receipt['result'] == {'got': 7}
+ assert receipt['preRevision'] == '0'
+ assert receipt['postRevision'] == '1'
+
+
+def test_run_tier_failure_partial_mutation(cleanup_registered):
+ _register_fake(cleanup_registered)
+
+ def _boom(doc, args):
+ raise RuntimeError('boom between ops')
+
+ native = define_action(name='acme.native-fail', description='d', run=_boom)
+ acme = extend_preset('fake-base', id='acme-native-fail', actions=[native])
+ register_preset(acme)
+ cleanup_registered.append('acme-native-fail')
+ receipt = acme.dispatch(_RevisionHandle(['3', '4']), 'superdoc_perform_action', {'action': 'acme.native-fail'})
+ assert receipt['status'] == 'failed'
+ assert receipt['partialMutation'] is True
+ assert receipt['recovery']['kind'] == 'revert'
+
+
+def test_run_tier_failure_clean_retry(cleanup_registered):
+ _register_fake(cleanup_registered)
+
+ def _boom(doc, args):
+ raise RuntimeError('failed before touching the doc')
+
+ native = define_action(name='acme.native-cf', description='d', run=_boom)
+ acme = extend_preset('fake-base', id='acme-native-cf', actions=[native])
+ register_preset(acme)
+ cleanup_registered.append('acme-native-cf')
+ receipt = acme.dispatch(_RevisionHandle(['5', '5']), 'superdoc_perform_action', {'action': 'acme.native-cf'})
+ assert receipt['status'] == 'failed'
+ assert receipt['partialMutation'] is False
+ assert receipt['recovery']['kind'] == 'retry'
+
+
+# ---------------------------------------------------------------------------
+# schema guards + one-call toolkit (review follow-ups)
+# ---------------------------------------------------------------------------
+
+
+def test_define_action_rejects_non_dict_input_schema():
+ class _Schemaish: # stand-in for a Zod/Pydantic schema object
+ pass
+
+ with pytest.raises(SuperDocError):
+ define_action(name='x.bad', description='d', input_schema=_Schemaish(),
+ run=lambda doc, args: None)
+
+
+@requires_host
+def test_create_agent_toolkit_actions_one_call():
+ from superdoc import create_agent_toolkit, list_presets
+
+ stamp = define_action(
+ name='superdoc.demo_stamp', description='Insert a demo banner.',
+ input_schema={'type': 'object', 'properties': {'label': {'type': 'string'}}},
+ steps=[{'action': 'insert_paragraphs', 'args': {'texts': ['{{label}}']}}],
+ )
+ kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp]})
+ perform = next(t for t in kit['tools'] if (t.get('function') or {}).get('name') == 'superdoc_perform_action')
+ names = perform['function']['parameters']['properties']['action']['enum']
+ assert 'superdoc.demo_stamp' in names # custom action advertised
+ assert 'superdoc.demo_stamp' in kit['system_prompt'] # prompt narrows WITH it
+ assert callable(kit['dispatch']) and callable(kit['dispatch_async'])
+ assert kit['meta']['preset'] == 'custom_superdoc_preset'
+ assert 'custom_superdoc_preset' not in list_presets() # ephemeral โ no global register
+
+
+@requires_host
+def test_create_agent_toolkit_actions_conflict_rejected():
+ from superdoc import create_agent_toolkit
+
+ a = define_action(name='x.a', description='a',
+ input_schema={'type': 'object', 'properties': {'foo': {'type': 'string'}}},
+ run=lambda doc, args: None)
+ b = define_action(name='x.b', description='b',
+ input_schema={'type': 'object', 'properties': {'foo': {'type': 'number'}}},
+ run=lambda doc, args: None)
+ with pytest.raises(SuperDocError):
+ create_agent_toolkit({'provider': 'openai', 'actions': [a, b]})
+
+
+@requires_host
+def test_create_agent_toolkit_identical_schema_reordered_keys_allowed():
+ from superdoc import create_agent_toolkit
+
+ a = define_action(name='x.a', description='a',
+ input_schema={'type': 'object',
+ 'properties': {'foo': {'type': 'string', 'description': 'the foo'}}},
+ run=lambda doc, args: None)
+ b = define_action(name='x.b', description='b',
+ input_schema={'type': 'object',
+ 'properties': {'foo': {'description': 'the foo', 'type': 'string'}}},
+ run=lambda doc, args: None)
+ kit = create_agent_toolkit({'provider': 'generic', 'actions': [a, b]})
+ perform = next(t for t in kit['tools'] if t.get('name') == 'superdoc_perform_action')
+ assert 'x.b' in perform['parameters']['properties']['action']['enum']
+
+
+@requires_host
+def test_create_agent_toolkit_shared_arg_compatible_desc_only_allowed():
+ """Reusing a built-in arg name with a compatible type + extra DESCRIPTION
+ only is allowed (only a structural difference conflicts)."""
+ from superdoc import create_agent_toolkit
+
+ a = define_action(name='x.a', description='a',
+ input_schema={'type': 'object', 'properties': {'foo': {'type': 'string'}}},
+ run=lambda doc, args: None)
+ b = define_action(name='x.b', description='b',
+ input_schema={'type': 'object',
+ 'properties': {'foo': {'type': 'string', 'description': 'documented'}}},
+ run=lambda doc, args: None)
+ kit = create_agent_toolkit({'provider': 'generic', 'actions': [a, b]})
+ perform = next(t for t in kit['tools'] if t.get('name') == 'superdoc_perform_action')
+ assert 'x.b' in perform['parameters']['properties']['action']['enum']
+
+
+@requires_host
+def test_create_agent_toolkit_shared_arg_one_sided_enum_rejected():
+ """A shared arg name where one side adds an enum the other lacks is a real
+ conflict (differs beyond description)."""
+ from superdoc import create_agent_toolkit
+
+ a = define_action(name='x.a', description='a',
+ input_schema={'type': 'object', 'properties': {'mode': {'type': 'string'}}},
+ run=lambda doc, args: None)
+ b = define_action(name='x.b', description='b',
+ input_schema={'type': 'object',
+ 'properties': {'mode': {'type': 'string', 'enum': ['fast', 'slow']}}},
+ run=lambda doc, args: None)
+ with pytest.raises(SuperDocError):
+ create_agent_toolkit({'provider': 'generic', 'actions': [a, b]})
+
+
+@requires_host
+def test_create_agent_toolkit_dispatch_runs_a_custom_action():
+ """Review item: run a custom action through the RETURNED toolkit dispatch."""
+ from superdoc import SuperDocClient, create_agent_toolkit
+
+ stamp = define_action(
+ name='superdoc.stamp_banner',
+ description='Insert a banner at the top of the document.',
+ input_schema={'type': 'object', 'properties': {'label': {'type': 'string', 'default': 'CONFIDENTIAL'}}},
+ steps=[{'action': 'insert_paragraphs',
+ 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}}],
+ )
+ kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp]})
+ with SuperDocClient() as client:
+ doc = client.open({}) # embedded blank document
+ receipt = kit['dispatch'](doc, 'superdoc_perform_action',
+ {'action': 'superdoc.stamp_banner', 'label': 'CONFIDENTIAL'})
+ assert receipt.get('status') == 'succeeded', receipt
+ texts = [b.get('text') or '' for b in doc.blocks.list({'includeText': True, 'limit': 6})['blocks']]
+ assert any('CONFIDENTIAL' in t for t in texts), texts
+ doc.close({'discard': True})
diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py
index 874364f4b7..7b6282b72a 100644
--- a/packages/sdk/langs/python/tests/test_presets.py
+++ b/packages/sdk/langs/python/tests/test_presets.py
@@ -284,6 +284,23 @@ def test_create_agent_toolkit_empty_preset_fails_fast():
assert exc_info.value.code == 'PRESET_NOT_FOUND'
+@pytest.mark.parametrize('base_selection', [
+ {'base': '', 'preset': 'core'},
+ {'preset': ''},
+])
+def test_create_agent_toolkit_custom_surface_empty_base_fails_fast(base_selection):
+ from superdoc import SuperDocError, create_agent_toolkit
+
+ with pytest.raises(SuperDocError) as exc_info:
+ create_agent_toolkit({
+ 'provider': 'generic',
+ 'includeCoreActions': [],
+ **base_selection,
+ })
+
+ assert exc_info.value.code == 'PRESET_NOT_FOUND'
+
+
def test_tools_api_all_exports_create_agent_toolkit():
import superdoc.tools_api as tools_api
diff --git a/packages/sdk/skills/superdoc-custom-actions/SKILL.md b/packages/sdk/skills/superdoc-custom-actions/SKILL.md
new file mode 100644
index 0000000000..e880c4e083
--- /dev/null
+++ b/packages/sdk/skills/superdoc-custom-actions/SKILL.md
@@ -0,0 +1,432 @@
+---
+name: superdoc-custom-actions
+description: >-
+ Author a custom SuperDoc LLM-tools "action" โ a named, deterministic
+ document-edit verb that plugs into the core preset's superdoc_perform_action
+ tool. Use when the user wants to add a new action (e.g. "build an action that
+ adds footnotes / bolds table borders / stamps a banner") or asks how to
+ extend SuperDoc's LLM tools with their own document operation. Works with
+ installed packages only โ no SuperDoc source checkout needed or expected.
+---
+
+# Build a SuperDoc custom action
+
+A **custom action** is a named, namespaced verb (`superdoc.`, e.g.
+`superdoc.add_footnote`) that a customer authors once and exposes to an LLM
+through `superdoc_perform_action`, alongside the 40 built-in core actions. An
+action is **deterministic application code**, not a prompt.
+
+Your job when this skill is active: turn a plain-language request into a
+correct, registered, **live-verified** ActionSpec, plus a test. Never hand back
+an action you haven't executed against a real document.
+
+---
+
+## 1. The canonical ActionSpec โ two execution tiers
+
+```
+ActionSpec = {
+ name: "superdoc." # namespaced; must not collide with built-ins
+ description: string # shown to the model: what it does + when to use it
+ input: JSON Schema (object) # the action's FLAT args; defaults are honored
+ # exactly ONE of:
+ steps: [ {action, args}, ... ] # TIER 1 โ declarative (PREFER THIS)
+ run: (doc, args) -> result # TIER 2 โ native function in your process
+}
+```
+
+**Tier selection rule โ always try in this order:**
+
+1. **`steps`** โ can the goal be expressed as a sequence of built-in core
+ actions? Then it MUST be steps: you inherit target resolution, placement
+ handling, receipts, and per-step verification that the built-ins already
+ guarantee. Steps are pure data (shippable as JSON) and behave identically
+ from Node and Python.
+2. **`run`** โ the goal needs an API domain the built-in actions don't cover
+ (footnotes, headers/footers, table borders, bookmarksโฆ) or computed logic.
+ A native function in YOUR language, called with the typed session-bound
+ `doc` handle.
+
+### Tier 1 example โ verified working
+
+```python
+from superdoc import define_action
+
+stamp = define_action(
+ name='superdoc.stamp_confidential',
+ description='Insert a confidentiality banner at the top and flag it with a review comment.',
+ input_schema={'type': 'object', 'properties': {
+ 'label': {'type': 'string', 'default': 'CONFIDENTIAL'},
+ }, 'required': []},
+ steps=[
+ {'action': 'insert_paragraphs',
+ 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}}},
+ {'action': 'add_comments',
+ 'args': {'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}'], 'occurrence': 1}],
+ 'commentText': 'Auto-stamped. Verify distribution before sending.'}},
+ ],
+)
+```
+
+Templating: `"{{label}}"` as a WHOLE string substitutes the raw value (arrays
+and objects survive); `"Stamped: {{label}}"` interpolates as text. A caller's
+`changeMode` flows into every step that doesn't pin its own. Receipts
+aggregate per step: `succeeded` / `partial` (some steps landed โ `failedStep`
+says which and why) / `failed`.
+
+### Tier 2 example โ verified working
+
+Take MODEL-FRIENDLY args (anchor text, ordinals โ things an LLM can actually
+produce) and resolve low-level targets INSIDE the action. Never expose raw
+`TextTarget`/`ref` shapes as action args.
+
+```python
+def _add_footnote(doc, args):
+ # Resolve anchor text -> a zero-width TextTarget right after the match.
+ # NOTE: use includeText + block['text'] โ the default textPreview field is
+ # truncated to 80 chars, so anchors later in a paragraph are never found.
+ # On large documents, paginate with offset/limit (see examples/reference_pack.py)
+ # โ one unpaginated call may not return every block.
+ needle = args['anchorText']
+ for block in doc.blocks.list({'includeText': True})['blocks']:
+ text = block.get('text') or ''
+ pos = text.find(needle)
+ if pos >= 0:
+ end = pos + len(needle)
+ at = {'kind': 'text', 'segments': [{'blockId': block['nodeId'], 'range': {'start': end, 'end': end}}]}
+ result = doc.footnotes.insert({'at': at, 'type': args.get('type', 'footnote'), 'content': args['content']})
+ after = doc.footnotes.list({}) # re-inspect: prove the footnote exists
+ return {'inserted': result, 'footnoteCount': after.get('total', len(after.get('items', [])))}
+ raise ValueError(f'anchorText {needle!r} not found in any block')
+
+add_footnote = define_action(
+ name='superdoc.add_footnote',
+ description='Insert a footnote whose marker lands right after the given anchor text. args: {anchorText, content, type?}.',
+ input_schema={'type': 'object', 'properties': {
+ 'anchorText': {'type': 'string'}, 'content': {'type': 'string'},
+ 'type': {'type': 'string', 'enum': ['footnote', 'endnote'], 'default': 'footnote'},
+ }, 'required': ['anchorText', 'content']},
+ run=_add_footnote,
+)
+```
+
+**Sync vs async โ match the host app's doc handle (this trips agents).** The
+example above is synchronous. But if the app dispatches through
+`dispatch_async` / drives an `AsyncSuperDocClient` (a very common real case โ
+check the app first), your `run` MUST be `async def` and MUST `await` every
+`doc.*` call. The kit awaits the value your run *returns*, but it does NOT make
+the inner `doc.*` calls awaitable for you โ a sync body on an async handle gets
+un-awaited coroutines and fails with `'coroutine' object is not subscriptable`.
+Async variant of the body above:
+
+```python
+async def _add_footnote(doc, args):
+ needle = args['anchorText']
+ for block in (await doc.blocks.list({'includeText': True}))['blocks']:
+ pos = (block.get('text') or '').find(needle)
+ if pos >= 0:
+ end = pos + len(needle)
+ at = {'kind': 'text', 'segments': [{'blockId': block['nodeId'], 'range': {'start': end, 'end': end}}]}
+ result = await doc.footnotes.insert({'at': at, 'type': args.get('type', 'footnote'), 'content': args['content']})
+ after = await doc.footnotes.list({})
+ return {'inserted': result, 'footnoteCount': after.get('total')}
+ raise ValueError(f'anchorText {needle!r} not found in any block')
+```
+
+`run` receipts are synthesized for you: `preRevision`/`postRevision`, and on
+failure `partialMutation` + a recovery hint. Raise on failure โ the exception
+becomes a truthful failed receipt with shape
+`{status:'failed', errors:[{code, message}], partialMutation, recovery}` โ the
+message lives at `errors[0].message` (NOT a top-level `error`); assert on that
+in your tests. The `examples/` directory next to this file
+contains complete, live-verified packs (footnotes + page headers, table
+borders) authored exactly this way (in the synchronous form โ apply the async
+rule above if the host is async).
+
+---
+
+## 2. Register + advertise
+
+**Simplest path โ hand your actions to the toolkit (no preset to manage):**
+
+```python
+from superdoc import create_agent_toolkit
+
+kit = create_agent_toolkit({'provider': 'openai', 'actions': [stamp, add_footnote]})
+# kit['tools'] enum + kit['system_prompt'] now include your actions;
+# kit['dispatch'] / kit['dispatch_async'] route them. No register_preset, no id.
+```
+
+`create_agent_toolkit({actions})` builds the extended preset internally over
+`core` and returns tools + system prompt + dispatch that already agree; provider
+shaping (anthropic/openai/vercel/generic) is automatic. (Node:
+`createAgentToolkit({ provider, actions })`.) Pass `base` to extend a preset
+other than `core`, or `includeCoreActions` to keep only a subset of the
+built-ins alongside yours. Schema enum, tool description, system prompt, and
+dispatch move together โ coherence is the kit's job, not yours.
+
+**Dispatch args are FLAT** โ `{action, ...yourArgs}`, never a nested `args`
+object:
+
+```python
+# async host (this uses dispatch_async); a sync host uses kit['dispatch']
+receipt = await kit['dispatch_async'](doc, 'superdoc_perform_action',
+ {'action': 'superdoc.add_footnote', 'anchorText': 'the report', 'content': 'See appendix.'})
+# NOT {'action': ..., 'args': {...}} โ a nested `args` raises
+# "Unknown argument(s) for superdoc_perform_action: args".
+```
+
+**Integrating into the host app โ find its toolkit seam.** Real apps differ;
+read the app before writing files:
+
+- The common case: the app already calls `create_agent_toolkit(...)` /
+ `createAgentToolkit(...)` (often in one startup function). Just add your
+ `actions` to THAT call โ one import + one arg. No `register_preset`, no
+ `PRESET` constant, no preset id threaded through dispatch.
+- If the app has an action-discovery convention (an `actions/` folder it
+ auto-loads, an `ACTIONS` list it imports), follow it and feed that list into
+ the toolkit call. Do not add scaffolding, CLI flags, or discovery frameworks
+ the app doesn't have.
+
+**Advanced โ a named, registered preset.** Only if the app uses the standalone
+functions (`choose_tools` / `get_system_prompt` / `dispatch_superdoc_tool`) or
+genuinely needs a reusable registered preset:
+
+```python
+from superdoc import extend_preset, register_preset
+
+register_preset(extend_preset('core', id='custom_superdoc_preset', actions=[stamp, add_footnote]))
+# then pass preset='custom_superdoc_preset' to choose_tools / get_system_prompt / dispatch_superdoc_tool
+```
+
+Reuse the app's existing extended-preset id if it has one; otherwise the
+conventional default is `custom_superdoc_preset` โ not an invented codename.
+
+---
+
+## 3. Discovery โ you have no source access, and you don't need it
+
+**Built-in action names + args** (decides steps-vs-run):
+
+```python
+from superdoc import choose_tools
+perform = next(t['function'] for t in choose_tools({'provider': 'openai', 'preset': 'core'})['tools']
+ if t['function']['name'] == 'superdoc_perform_action')
+print(perform['parameters']['properties']['action']['enum']) # the 40 built-ins
+print(sorted(perform['parameters']['properties'])) # every advertised arg
+print(perform['description']) # grouped hints per action
+```
+
+**Native `doc.*` surface** (for the run tier) โ the client self-describes; use
+that FIRST, it beats trial-and-error:
+
+```python
+from superdoc import SuperDocClient
+with SuperDocClient() as client:
+ contract = client.describe() # ALL operations (~439) with ids like 'doc.tables.setBorders'
+ print(sorted({op['id'].split('.')[1] for op in contract['operations']})) # the namespaces
+ spec = client.describe_command({'operationId': 'tables.setBorders'}) # full input contract for one op
+ print(spec['operation'])
+```
+
+Then confirm against a live session on a COPY of a sample doc:
+
+```python
+ doc = client.open({'doc': 'work-copy.docx'})
+ print([m for m in dir(doc.tables) if not m.startswith('_')]) # methods (snake_case + camelCase both work)
+ print(doc.blocks.list()['blocks'][:3]) # rows: ordinal, nodeId, nodeType, textPreview, ref
+ doc.close({'discard': True})
+```
+
+Note: `dir(doc)` does NOT list the namespaces (they're dynamic attributes on
+the Python handle) โ `client.describe()` is the namespace directory;
+`dir(doc.)` works once you know the namespace.
+
+**Let errors teach you โ but confirm params with `describe_command`, never
+with a successful call.** Most inputs are validated against the real contract
+and error messages enumerate the valid unions. Two traps, both observed live:
+
+- Some ops **silently drop unknown params**: `headerFooters.parts.create`
+ returns `success: true` for `{'content': ...}` while writing nothing โ the
+ success receipt lies about the param having done anything.
+- Union error messages can mislead: `blocks.list({'in': ...})` prints
+ `in.kind must be one of: "story","story","story"` and `storyType must equal
+ "body"` even though richer story kinds are valid. Only
+ `describe_command({'operationId': 'blocks.list'})` shows the real union.
+
+Budget for iteration: some ops reveal ONE missing required field per probe
+(`tables.setBorders` took six rounds that way โ `describe_command` gives the
+whole contract in one).
+
+**Stories (header/footer content).** Body is only one story of a document.
+Header/footer text is written with ordinary content ops pointed INTO a story:
+`doc.create.paragraph({..., 'in': {'kind':'story','storyType':'headerFooterPart','refId': ...}})`,
+or a slot story with `onWrite: 'materializeIfInherited'` (auto-creates and
+wires the part). Wiring evidence comes from `doc.headerFooters.get(...)`
+(refId/isExplicit); **header/footer TEXT has no in-session read-back**
+(`blocks.list` returns body blocks regardless of `in`, `doc.get` omits header
+parts) โ save to a file and inspect the zip (`word/header1.xml`) for
+text-level evidence.
+
+**Finding table/row/cell handles:** `doc.blocks.list()` rows with
+`nodeType == 'table'` carry the table `nodeId`; `doc.tables.get_cells(...)` /
+`doc.tables.get_properties(...)` take it from there (probe their input shape
+with a small call and read the validation error).
+
+---
+
+## 4. Verification โ two layers; degrade honestly, never silently
+
+**Layer 1 โ contract validation. ALWAYS, no document needed.** Every operation
+your action calls must be confirmed against
+`client.describe_command({'operationId': ...})`: the op exists and your arg
+shapes match its contract. Never confirm a param with a successful call โ
+some ops silently drop unknown params and report success anyway. This layer is
+seconds of work and catches most authoring mistakes.
+
+**Layer 2 โ live semantic verification. When a document is available.** A run
+on a COPY of a real document showing a `succeeded` receipt AND independent
+evidence the document actually changed (re-inspect: `doc.blocks.list()` shows
+the banner at ordinal 0, `doc.footnotes.list({})` count went up, borders read
+back different; for header/footer text, saved-zip XML).
+
+**Finding a document for layer 2, in order:**
+
+1. Glob the workspace for `*.docx`, skipping outputs (`out*.docx`), Word lock
+ files (`~$*`), and `.venv`/`node_modules`/state dirs. Prefer conventional
+ homes first: `sample_docs/`, `fixtures/`, `tests/data/`.
+2. Check suitability, not just existence: open a COPY, one `countsOnly`
+ inspect โ does it contain what the action targets (images, footnotes,
+ tables)? If not, can you PLANT the conditions on the copy first (built-in
+ actions or raw ops, e.g. `doc.footnotes.insert` before testing a renumber
+ action)?
+3. No usable document? **Open a blank one โ built in.** `client.open({})`
+ (no `doc` at all) starts a session from the SDK's embedded blank document,
+ in both languages; `doc.save({'out': ...})` materializes it as a real file.
+ PLANT the conditions your action needs on it (built-in actions or raw ops,
+ e.g. `create_table`, `doc.footnotes.insert`), then verify against it.
+4. Conditions you cannot synthesize (images, embedded objects, messy
+ real-world structure) and a human is present: **ask the user for a
+ representative document.** This is the one legitimate reason to stop and
+ ask.
+5. Nobody to ask (headless/CI) and synthesis impossible: **deliver anyway, as
+ a DRAFT** โ do not block, do not fabricate.
+
+A generated-fixture verification is real layer-2 evidence for the mechanics,
+but say so in your summary ("verified against a generated fixture") โ it
+proves the action works, not that it handles the customer's real documents.
+
+**Draft labeling โ when layer 2 did not run:** the action file's docstring AND
+your final message must say plainly: *"DRAFT โ contract-validated, not yet
+executed against a document."* Still write the pytest (see below) so the
+customer's first real run IS the verification. Never present an unexecuted
+action as verified; a false "tested โ" is worse than no action at all.
+
+**A test is required in BOTH modes** โ in `tests/`, following the existing
+suite's pattern; if the app has NO test suite yet, create `tests/` plus a
+conftest that wires the environment the same way the app does (e.g. read
+`SUPERDOC_CLI_BIN` from the app's `.env`). Tests open a fresh doc (or tmp
+copy), dispatch through the toolkit (or a registered preset), and assert receipt
+fields plus re-inspected evidence. Failure honesty matters โ if your action can partially
+apply, test that path too.
+
+Two receipt/fixture facts your tests must account for:
+- **Status vocabulary differs by layer**: custom-action receipts report
+ `succeeded`/`partial`/`failed`, but dispatching a BUILT-IN action directly
+ (e.g. while planting conditions) returns `status: "ok"`. Accept both where
+ appropriate.
+- **A truly blank document interleaves empty paragraphs** around inserted
+ texts (`['', 'AAA', '', 'BBB']`), so tail/count assertions against the bare
+ embedded blank doc are unstable โ plant body content first, then assert.
+
+Never claim success from `status: 'succeeded'` alone โ receipts synthesized for
+`run`-tier actions confirm your code returned, not that the effect you intended
+landed. Re-inspect.
+
+**Clean up when you finish.** Verification leaves scratch behind โ delete it so
+the only new files are the action module and its test. Remove: any `.docx` you
+opened/saved/generated for verification, session-state dirs the host created
+(e.g. `.superdoc-state/`, `.superdoc-cli-state/`), a stray `out.docx`, and
+`__pycache__/` from test runs. Do NOT delete the app's own sample documents,
+seed assets, or anything that existed before you started. When in doubt, list
+what you created and remove exactly that.
+
+"Before" evidence can legitimately be ABSENT: style-inherited formatting reads
+back with no explicit property at all (e.g. a `TableGrid`-styled table has no
+`borders` key until direct borders are set). Compare "absent โ explicit value",
+don't assume a numeric before-state exists.
+
+Read-back may NORMALIZE identifiers (e.g. a bookmark inserted with one blockId
+can read back with a normalized id). Verify by stable evidence โ names,
+offsets, counts, saved-XML content โ not by identifier equality.
+
+---
+
+## 5. Naming, schema, and description discipline
+
+- Names: `superdoc.` โ dots are valid in the merged enum. (Only
+ `standalone` mode forbids dots, because provider tool names must match
+ `^[A-Za-z0-9_-]{1,64}$`.)
+- Never collide with built-in names โ the kit rejects it.
+- `description` is model-facing: one sentence on WHAT + one on WHEN, and name
+ the args inline (models call better with `args: {at, content}` spelled out).
+- Every arg gets a JSON-Schema property; put `default` on optionals โ defaults
+ are applied on all tiers. Mark true requirements in `required`.
+
+## 6. Pitfalls (each of these bit a real implementation)
+
+- **Reusing a built-in arg name:** custom actions merge into the ONE
+ `superdoc_perform_action` schema, so a shared arg name maps to a single
+ schema. Reusing a built-in name (`anchorText`, `text`, `caseSensitive`, โฆ) is
+ fine **only if your schema matches the built-in's exactly except for
+ `description`** โ a differing `type`, `enum` (including one side having one),
+ `default`, limit, or pattern is a real conflict and the kit rejects it (the
+ merged surface can advertise only one schema per name). A description-only
+ difference is allowed, but the built-in's schema wins the merge so your
+ description is not advertised โ use a distinct arg name if you need it. Cross-
+ check with ยง3's `sorted(perform['parameters']['properties'])`.
+- **Async host apps (very common โ inspect the app first):** if it dispatches
+ via `dispatch_async` or an `AsyncSuperDocClient`, your `run` function MUST be
+ `async def` and `await` every `doc.*` call. A synchronous body on an async
+ handle hands back un-awaited coroutines and fails with `'coroutine' object is
+ not subscriptable`. (See ยง1's async variant.) A synchronous app is the
+ mirror: a plain `def run`.
+- **Do not insert content via raw ops in `run` actions** โ e.g.
+ `doc.create.paragraph({placement: 'before', ...})` silently IGNORES placement
+ and appends at document end. The built-in `insert_paragraphs` action resolves
+ placement correctly โ compose it via `steps` instead (or mix: a steps action
+ for the insert, run for the rest).
+- **Comment anchoring:** `doc.comments.create` with a `{text: ...}` target can
+ return success without a visible comment. Use the built-in `add_comments`
+ action (selector-resolved, verified) via `steps`.
+- **Two failure shapes:** argument-validation errors THROW; runtime failures
+ return `{'status': 'failed', ...}` receipts. Wrap dispatch in try/except and
+ handle both.
+- **Refs expire on mutation.** Any `ref` you fetched before an edit is invalid
+ after it โ re-list/re-search instead of caching handles across mutations.
+- **Python handles return plain dicts** and accept both `snake_case` and
+ `camelCase` method names; results use camelCase keys (`textPreview`,
+ `nodeId`).
+- **Some open string inputs are NOT enum-validated by the host** (e.g.
+ `tables.setBorders` persists any `lineStyle` string verbatim โ `zigzag`
+ round-trips). Constrain such inputs with an `enum` in YOUR action's input
+ schema so the model can't invent values.
+- **`insert_paragraphs` rejects empty strings in `texts`** โ you cannot
+ compose a blank spacer paragraph declaratively; a `""` entry fails the whole
+ action. Design steps-tier layouts without empty-line spacers.
+- **Sandboxed agent environments:** the SDK's host bootstrap may `chmod` its
+ bundled binary and write state under `~/.superdoc-cli` โ both can be blocked
+ by an agent sandbox. If `client.open` fails on permissions, set
+ `SUPERDOC_CLI_BIN` to the installed binary and `SUPERDOC_CLI_STATE_DIR` to a
+ writable path inside your workspace.
+
+## 7. Worked examples
+
+The `examples/` directory next to this file contains complete, live-verified
+action packs (declarative steps, footnotes/page headers, table borders) with
+their receipt-synthesis and verification patterns โ in Python and JavaScript
+(`policy_pack.py` / `policy_pack.mjs` are twins; the kit API maps 1:1 across
+languages). Pattern-match them. There is
+no bundled API reference by design โ the live contract from `client.describe()`
+/ `client.describe_command(...)` (ยง3) is always current; a bundled copy would
+drift.
diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs
new file mode 100644
index 0000000000..012bfcdf5a
--- /dev/null
+++ b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.mjs
@@ -0,0 +1,68 @@
+/**
+ * ACME's in-house custom actions โ Node/JavaScript twin of policy_pack.py.
+ * The kit API maps 1:1 across languages: defineAction/extendPreset/
+ * registerPreset here are define_action/extend_preset/register_preset there,
+ * and steps/run specs behave identically (same templating, same receipts).
+ *
+ * Two tiers demonstrated:
+ * - `steps` (declarative): compose built-in core actions โ inherits target
+ * resolution, placement handling, receipts, and verification.
+ * - `run` (native): an async function against the typed doc handle, for
+ * things the built-in actions can't express.
+ *
+ * Wire into your app at its toolkit seam โ hand the actions to the toolkit
+ * (no registerPreset, no preset id):
+ * import { createAgentToolkit } from '@superdoc-dev/sdk';
+ * import { ACTIONS } from './actions/policy_pack.mjs';
+ * const kit = await createAgentToolkit({ provider: 'openai', actions: ACTIONS });
+ */
+
+import { defineAction } from '@superdoc-dev/sdk';
+
+export const stampConfidential = defineAction({
+ name: 'superdoc.stamp_confidential',
+ description:
+ 'Insert a confidentiality banner paragraph at the very top of the document ' +
+ 'and flag it with a review comment for the distribution team.',
+ input: {
+ type: 'object',
+ properties: {
+ label: { type: 'string', default: 'CONFIDENTIAL', description: 'Banner text' },
+ },
+ required: [],
+ },
+ steps: [
+ {
+ action: 'insert_paragraphs',
+ args: { texts: ['{{label}}'], placement: { at: 'document_start' } },
+ },
+ {
+ action: 'add_comments',
+ args: {
+ selectors: [{ kind: 'textSearch', terms: ['{{label}}'], occurrence: 1 }],
+ commentText: 'Auto-stamped by ACME policy. Verify the distribution list before sending.',
+ },
+ },
+ ],
+});
+
+export const docStats = defineAction({
+ name: 'superdoc.doc_stats',
+ description: 'Report document structure statistics: block/table/comment counts and empty-block count.',
+ input: { type: 'object', properties: {}, required: [] },
+ async run(doc, _args) {
+ // Cross-domain read the built-in actions can't express as one verb.
+ const blocks = await doc.blocks.list();
+ const comments = await doc.comments.list({});
+ const rows = blocks.blocks ?? [];
+ const commentItems = comments.comments ?? comments.items ?? [];
+ return {
+ blocks: blocks.total,
+ emptyBlocks: rows.filter((b) => b.isEmpty).length,
+ tables: rows.filter((b) => b.nodeType === 'table').length,
+ comments: commentItems.length,
+ };
+ },
+});
+
+export const ACTIONS = [stampConfidential, docStats];
diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py
new file mode 100644
index 0000000000..fcd4f55040
--- /dev/null
+++ b/packages/sdk/skills/superdoc-custom-actions/examples/policy_pack.py
@@ -0,0 +1,65 @@
+"""ACME's in-house custom actions for SuperDoc agents.
+
+Two tiers demonstrated:
+ - `steps` (declarative): compose built-in core actions โ inherits target
+ resolution, placement handling, receipts, and verification.
+ - `run` (native): a Python function against the typed doc handle, for things
+ the built-in actions can't express.
+
+Every module in this directory that exposes an ``ACTIONS`` list is loaded by
+app.py and merged into the agent's tool surface.
+"""
+
+from superdoc import define_action
+
+stamp_confidential = define_action(
+ name='superdoc.stamp_confidential',
+ description=(
+ 'Insert a confidentiality banner paragraph at the very top of the document '
+ 'and flag it with a review comment for the distribution team.'
+ ),
+ input_schema={
+ 'type': 'object',
+ 'properties': {
+ 'label': {'type': 'string', 'default': 'CONFIDENTIAL', 'description': 'Banner text'},
+ },
+ 'required': [],
+ },
+ steps=[
+ {
+ 'action': 'insert_paragraphs',
+ 'args': {'texts': ['{{label}}'], 'placement': {'at': 'document_start'}},
+ },
+ {
+ 'action': 'add_comments',
+ 'args': {
+ 'selectors': [{'kind': 'textSearch', 'terms': ['{{label}}'], 'occurrence': 1}],
+ 'commentText': 'Auto-stamped by ACME policy. Verify the distribution list before sending.',
+ },
+ },
+ ],
+)
+
+
+def _doc_stats(doc, args):
+ """Cross-domain read the built-in actions can't express as one verb."""
+ blocks = doc.blocks.list()
+ comments = doc.comments.list({})
+ rows = blocks['blocks']
+ comment_items = comments.get('comments') or comments.get('items') or []
+ return {
+ 'blocks': blocks['total'],
+ 'emptyBlocks': sum(1 for b in rows if b.get('isEmpty')),
+ 'tables': sum(1 for b in rows if b.get('nodeType') == 'table'),
+ 'comments': len(comment_items),
+ }
+
+
+doc_stats = define_action(
+ name='superdoc.doc_stats',
+ description='Report document structure statistics: block/table/comment counts and empty-block count.',
+ input_schema={'type': 'object', 'properties': {}, 'required': []},
+ run=_doc_stats,
+)
+
+ACTIONS = [stamp_confidential, doc_stats]
diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py b/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py
new file mode 100644
index 0000000000..3cceeebbf7
--- /dev/null
+++ b/packages/sdk/skills/superdoc-custom-actions/examples/reference_pack.py
@@ -0,0 +1,275 @@
+"""ACME custom actions: footnotes + page headers (both `run` tier).
+
+Why run tier (SKILL.md ยง1 tier rule): neither domain is covered by the 40
+built-in core actions โ the `superdoc_perform_action` enum has no footnote or
+header/footer verbs โ and the built-in `insert_paragraphs` placement cannot
+target a header/footer story. So these are native Python functions against the
+typed doc handle.
+
+Discovery notes (probed live, not guessed โ SKILL.md ยง3):
+ - `doc.footnotes.insert({at, type, content})` anchors a note at a TextTarget;
+ a zero-width range `{start: N, end: N}` puts the reference marker right
+ after the anchor text (verified in word/document.xml + word/footnotes.xml).
+ - `doc.headerFooters.parts.create({kind})` creates an EMPTY part and returns
+ `{refId, partPath}`. It has NO content parameter โ unknown params are
+ silently ignored (a `content` arg "succeeds" but writes nothing).
+ - Header text is written by `doc.create.paragraph({text, in: })`
+ with `in = {kind:'story', storyType:'headerFooterPart', refId}`, then the
+ section slot is wired with `doc.headerFooters.refs.set({target, refId})`
+ where target is `{kind:'headerFooterSlot', section, headerFooterKind,
+ variant}`. Creating a fresh part per call gives true SET/replace semantics.
+ - `doc.blocks.list({'in': })` validates the story locator but does NOT
+ honor it (returns body blocks), so header read-back uses
+ `doc.headerFooters.get` (refId + isExplicit) in-session; text-level
+ evidence requires save + zip inspection (done in tests).
+"""
+
+from superdoc import define_action
+
+_PAGE = 200
+
+
+def _iter_text_blocks(doc):
+ """Paginate body blocks with full text, by stable nodeId."""
+ offset = 0
+ total = None
+ while total is None or offset < total:
+ page = doc.blocks.list({'offset': offset, 'limit': _PAGE, 'includeText': True})
+ total = page['total']
+ blocks = page.get('blocks') or []
+ if not blocks:
+ break
+ yield from blocks
+ offset += len(blocks)
+
+
+def _find_anchor(doc, anchor_text, occurrence, case_sensitive):
+ """Locate the Nth non-overlapping occurrence of anchor_text in reading
+ order. Returns (block_nodeId, offset_after_match, matches_seen)."""
+ needle = anchor_text if case_sensitive else anchor_text.lower()
+ seen = 0
+ for block in _iter_text_blocks(doc):
+ text = block.get('text') or ''
+ hay = text if case_sensitive else text.lower()
+ start = 0
+ while True:
+ idx = hay.find(needle, start)
+ if idx == -1:
+ break
+ seen += 1
+ if seen == occurrence:
+ return block['nodeId'], idx + len(anchor_text), seen
+ start = idx + len(needle)
+ return None, None, seen
+
+
+# ---------------------------------------------------------------------------
+# superdoc.add_footnote โ run tier
+# ---------------------------------------------------------------------------
+
+def _add_footnote(doc, args):
+ anchor_text = str(args.get('anchorText') or '')
+ content = str(args.get('content') or '')
+ if not anchor_text.strip():
+ raise ValueError('superdoc.add_footnote: "anchorText" must be a non-empty string.')
+ if not content.strip():
+ raise ValueError('superdoc.add_footnote: "content" must be a non-empty string.')
+ raw_occurrence = args.get('occurrence')
+ occurrence = 1 if raw_occurrence is None else int(raw_occurrence)
+ if occurrence < 1:
+ raise ValueError('superdoc.add_footnote: "occurrence" must be >= 1.')
+ note_type = args.get('type') or 'footnote'
+ case_sensitive = args.get('caseSensitive') is True
+
+ block_id, end_offset, seen = _find_anchor(doc, anchor_text, occurrence, case_sensitive)
+ if block_id is None:
+ raise ValueError(
+ f'superdoc.add_footnote: anchor text {anchor_text!r} occurrence {occurrence} '
+ f'not found in the document body (found {seen} match(es)).'
+ )
+
+ before = doc.footnotes.list({'type': note_type})
+ before_count = before['total']
+
+ receipt = doc.footnotes.insert({
+ # Zero-width target = insertion point immediately AFTER the anchor text,
+ # which is where Word places a footnote reference marker.
+ 'at': {'kind': 'text', 'segments': [{'blockId': block_id, 'range': {'start': end_offset, 'end': end_offset}}]},
+ 'type': note_type,
+ 'content': content,
+ })
+ note = (receipt or {}).get('footnote') or {}
+ note_id = note.get('noteId')
+ if not (isinstance(receipt, dict) and receipt.get('success') and note_id is not None):
+ raise RuntimeError(f'superdoc.add_footnote: footnotes.insert did not return a note id: {receipt!r}')
+
+ # Read-back verification (SKILL.md ยง4): the note count grew by exactly one
+ # and the created note carries our content. Never trust the insert receipt.
+ after = doc.footnotes.list({'type': note_type})
+ if after['total'] != before_count + 1:
+ raise RuntimeError(
+ f'superdoc.add_footnote: verification failed โ expected {note_type} count '
+ f'{before_count + 1}, read back {after["total"]}.'
+ )
+ created = next((item for item in after.get('items', []) if item.get('noteId') == note_id), None)
+ if created is None or created.get('content') != content:
+ raise RuntimeError(
+ f'superdoc.add_footnote: verification failed โ note {note_id} content mismatch '
+ f'(read back {created!r}).'
+ )
+
+ return {
+ 'noteId': note_id,
+ 'displayNumber': created.get('displayNumber'),
+ 'type': note_type,
+ 'anchorBlockId': block_id,
+ 'anchorOffset': end_offset,
+ 'notesOfTypeAfter': after['total'],
+ }
+
+
+add_footnote = define_action(
+ name='superdoc.add_footnote',
+ description=(
+ 'Insert a footnote (or endnote) whose reference marker lands immediately after the Nth '
+ 'occurrence of a body text snippet. Use when a citation, source, or clarification must be '
+ 'attached to specific wording. args: {anchorText, content, occurrence?, type?, caseSensitive?}.'
+ ),
+ input_schema={
+ 'type': 'object',
+ 'properties': {
+ 'anchorText': {
+ 'type': 'string',
+ 'description': 'Body text to anchor on; the note marker is placed right after this text.',
+ },
+ 'content': {'type': 'string', 'description': 'The note body text.'},
+ 'occurrence': {
+ 'type': 'number',
+ 'default': 1,
+ 'description': '1-based occurrence of anchorText to anchor on (reading order).',
+ },
+ 'type': {'type': 'string', 'enum': ['footnote', 'endnote'], 'default': 'footnote'},
+ # `caseSensitive` is also a built-in arg (bare boolean); to reuse the
+ # name we keep the schema identical except for the description (a
+ # differing default/enum/limit would be a real conflict). The run
+ # body applies the false default itself.
+ 'caseSensitive': {
+ 'type': 'boolean',
+ 'description': 'Match anchorText case-sensitively. Defaults to false.',
+ },
+ },
+ 'required': ['anchorText', 'content'],
+ },
+ run=_add_footnote,
+)
+
+
+# ---------------------------------------------------------------------------
+# superdoc.set_page_header โ run tier
+# ---------------------------------------------------------------------------
+
+_VARIANTS = ('default', 'first', 'even')
+
+
+def _set_page_header(doc, args):
+ text = str(args.get('text') or '')
+ if not text.strip():
+ raise ValueError('superdoc.set_page_header: "text" must be a non-empty string.')
+ variant = args.get('variant') or 'default'
+ if variant not in _VARIANTS:
+ raise ValueError(f'superdoc.set_page_header: "variant" must be one of {list(_VARIANTS)}.')
+
+ sections = doc.sections.list({})['items']
+ section_index = args.get('sectionIndex')
+ if section_index is None:
+ targets = sections
+ else:
+ index = int(section_index)
+ if index < 0 or index >= len(sections):
+ raise ValueError(
+ f'superdoc.set_page_header: sectionIndex {index} out of range '
+ f'(document has {len(sections)} section(s)).'
+ )
+ targets = [sections[index]]
+
+ if variant == 'even':
+ # Even-page headers only render when odd/even headers are enabled.
+ doc.sections.set_odd_even_headers_footers({'enabled': True})
+
+ slots = []
+ for section in targets:
+ section_id = section['id']
+ part = doc.header_footers.parts.create({'kind': 'header'})
+ ref_id = part.get('refId') if isinstance(part, dict) else None
+ if not (isinstance(part, dict) and part.get('success') and ref_id):
+ raise RuntimeError(f'superdoc.set_page_header: parts.create failed: {part!r}')
+
+ # Write the header text into the FRESH part's story. parts.create has no
+ # content parameter (probed: unknown params are silently ignored), so the
+ # paragraph must be created inside the part story explicitly.
+ para = doc.create.paragraph({
+ 'text': text,
+ 'in': {'kind': 'story', 'storyType': 'headerFooterPart', 'refId': ref_id},
+ })
+ if not (isinstance(para, dict) and para.get('success')):
+ raise RuntimeError(f'superdoc.set_page_header: create.paragraph failed for part {ref_id}: {para!r}')
+
+ # Point the section slot at the new part โ replaces any previous header.
+ slot_target = {
+ 'kind': 'headerFooterSlot',
+ 'section': {'kind': 'section', 'sectionId': section_id},
+ 'headerFooterKind': 'header',
+ 'variant': variant,
+ }
+ wired = doc.header_footers.refs.set({'target': slot_target, 'refId': ref_id})
+ if not (isinstance(wired, dict) and wired.get('success')):
+ raise RuntimeError(f'superdoc.set_page_header: refs.set failed for section {section_id}: {wired!r}')
+
+ if variant == 'first':
+ # First-page headers only render when the section has a title page.
+ doc.sections.set_title_page({'target': {'kind': 'section', 'sectionId': section_id}, 'enabled': True})
+
+ # Read-back verification (SKILL.md ยง4): the slot must now explicitly
+ # reference OUR part. (Header text itself is not readable in-session โ
+ # blocks.list story scoping is not honored โ so tests additionally save
+ # and inspect the .docx part XML.)
+ read_back = doc.header_footers.get({'target': slot_target})
+ if not (isinstance(read_back, dict) and read_back.get('isExplicit') and read_back.get('refId') == ref_id):
+ raise RuntimeError(
+ f'superdoc.set_page_header: verification failed for section {section_id} โ '
+ f'slot reads {read_back!r}, expected explicit refId {ref_id}.'
+ )
+ slots.append({'sectionId': section_id, 'refId': ref_id, 'partPath': part.get('partPath')})
+
+ return {'text': text, 'variant': variant, 'sectionsUpdated': len(slots), 'slots': slots}
+
+
+set_page_header = define_action(
+ name='superdoc.set_page_header',
+ description=(
+ 'Set (replace) the page header text for every section, or one section via sectionIndex. '
+ 'Creates a fresh header part, writes the text into it, and wires the section header slot '
+ 'to it; variant "first"/"even" also enables the title-page / odd-even setting so the '
+ 'header actually renders. args: {text, variant?, sectionIndex?}.'
+ ),
+ input_schema={
+ 'type': 'object',
+ 'properties': {
+ 'text': {'type': 'string', 'description': 'The header paragraph text.'},
+ 'variant': {
+ 'type': 'string',
+ 'enum': list(_VARIANTS),
+ 'default': 'default',
+ 'description': 'Which header slot to set: default (all pages), first (first page), even (even pages).',
+ },
+ 'sectionIndex': {
+ 'type': 'number',
+ 'description': '0-based section to target. Omit to set the header on every section.',
+ },
+ },
+ 'required': ['text'],
+ },
+ run=_set_page_header,
+)
+
+ACTIONS = [add_footnote, set_page_header]
diff --git a/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py b/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py
new file mode 100644
index 0000000000..4daac690db
--- /dev/null
+++ b/packages/sdk/skills/superdoc-custom-actions/examples/table_borders.py
@@ -0,0 +1,138 @@
+"""ACME custom action: make a table's borders bold (heavier weight / style).
+
+Tier choice: ``run`` (native Python). No built-in core action exposes border
+weight/style โ ``style_table`` applies a fixed "professional look" preset and
+offers no border control โ so this composes the Document API's
+``doc.tables.set_borders`` directly.
+
+Probed contract (superdoc-sdk 1.20.0, learned from live validation errors):
+ doc.tables.set_borders({
+ 'mode': 'applyTo', # 'applyTo' | 'edges'
+ 'nodeId': , # or a resolved target
+ 'applyTo': 'all', # all|outside|inside|top|bottom|left|right|insideH|insideV
+ 'border': {'lineStyle': str, 'lineWeightPt': float > 0, 'color': str}, # ALL required
+ })
+ doc.tables.get_properties({'nodeId': ...}) # -> {'borders': {side: {...}}} once set
+
+NOTE: the host stores ``lineStyle`` verbatim (even nonsense values), so this
+action constrains it to known OOXML styles via its input schema.
+"""
+
+from superdoc import define_action
+
+# Which border sides each applyTo mode is expected to touch โ used to verify
+# the mutation actually landed by reading properties back.
+_SIDES_FOR_APPLY_TO = {
+ 'all': ('top', 'bottom', 'left', 'right', 'insideH', 'insideV'),
+ 'outside': ('top', 'bottom', 'left', 'right'),
+ 'inside': ('insideH', 'insideV'),
+ 'top': ('top',),
+ 'bottom': ('bottom',),
+ 'left': ('left',),
+ 'right': ('right',),
+ 'insideH': ('insideH',),
+ 'insideV': ('insideV',),
+}
+
+
+def _norm_color(value):
+ return str(value or '').lstrip('#').upper()
+
+
+def _embolden_table_borders(doc, args):
+ ordinal = int(args.get('tableOrdinal', 1))
+ apply_to = args.get('applyTo', 'all')
+ border = {
+ 'lineStyle': args.get('lineStyle', 'single'),
+ 'lineWeightPt': args.get('lineWeightPt', 2.25),
+ 'color': args.get('color', '#000000'),
+ }
+
+ tables = [b for b in doc.blocks.list()['blocks'] if b.get('nodeType') == 'table']
+ if not tables:
+ raise ValueError('No tables in this document.')
+ if not 1 <= ordinal <= len(tables):
+ raise ValueError(
+ f'tableOrdinal {ordinal} is out of range: the document has '
+ f'{len(tables)} table(s) (ordinals 1..{len(tables)}).'
+ )
+ node_id = tables[ordinal - 1]['nodeId']
+
+ before = doc.tables.get_properties({'nodeId': node_id}).get('borders')
+
+ doc.tables.set_borders({
+ 'mode': 'applyTo',
+ 'nodeId': node_id,
+ 'applyTo': apply_to,
+ 'border': border,
+ })
+
+ # Re-inspect: never trust the call's own success flag alone.
+ after = doc.tables.get_properties({'nodeId': node_id}).get('borders') or {}
+ mismatched = [
+ side for side in _SIDES_FOR_APPLY_TO[apply_to]
+ if (after.get(side) or {}).get('lineStyle') != border['lineStyle']
+ or (after.get(side) or {}).get('lineWeightPt') != border['lineWeightPt']
+ or _norm_color((after.get(side) or {}).get('color')) != _norm_color(border['color'])
+ ]
+ if mismatched:
+ raise RuntimeError(
+ f'Border update did not land on side(s) {mismatched}: read-back {after!r}.'
+ )
+
+ return {
+ 'tableOrdinal': ordinal,
+ 'nodeId': node_id,
+ 'applyTo': apply_to,
+ 'applied': border,
+ 'bordersBefore': before,
+ 'bordersAfter': after,
+ }
+
+
+embolden_table_borders = define_action(
+ name='superdoc.embolden_table_borders',
+ description=(
+ 'Make a table\'s borders bold: apply a heavier border weight and/or style to a table. '
+ 'Use when asked to bold/thicken/emphasize table borders. '
+ 'args: {tableOrdinal? (1-based, default 1), applyTo? (all|outside|inside|top|bottom|left|right|insideH|insideV, '
+ 'default all), lineWeightPt? (default 2.25), lineStyle? (default single), color? (hex, default #000000)}.'
+ ),
+ input_schema={
+ 'type': 'object',
+ 'properties': {
+ 'tableOrdinal': {
+ 'type': 'number',
+ 'default': 1,
+ 'description': '1-based table position in the document (1 = first table).',
+ },
+ 'applyTo': {
+ 'type': 'string',
+ 'enum': ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'],
+ 'default': 'all',
+ 'description': 'Which borders to embolden (default: every border, outer and inner).',
+ },
+ 'lineWeightPt': {
+ 'type': 'number',
+ 'exclusiveMinimum': 0,
+ 'default': 2.25,
+ 'description': 'Border line weight in points (Word default grid is 0.5pt; 2.25pt reads as bold).',
+ },
+ 'lineStyle': {
+ 'type': 'string',
+ 'enum': ['single', 'thick', 'double', 'dashed', 'dotted'],
+ 'default': 'single',
+ 'description': 'OOXML border line style.',
+ },
+ 'color': {
+ 'type': 'string',
+ 'default': '#000000',
+ 'description': 'Border color as hex, e.g. #000000.',
+ },
+ },
+ 'required': [],
+ },
+ run=_embolden_table_borders,
+)
+
+ACTIONS = [embolden_table_borders]
diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs
index b80d38b795..35332f1faf 100644
--- a/packages/superdoc/.releaserc.cjs
+++ b/packages/superdoc/.releaserc.cjs
@@ -61,17 +61,29 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = !isLocalPreview && Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
const notesPlugin =
- isLocalPreview || isPrerelease ? createReleaseNotesGenerator() : ['semantic-release-ai-notes', { style: 'concise' }];
+ isLocalPreview || isPrerelease
+ ? createReleaseNotesGenerator()
+ : [
+ 'semantic-release-ai-notes',
+ {
+ style: 'concise',
+ scope: {
+ name: 'SuperDoc',
+ paths: SUPERDOC_PACKAGES,
+ audience: 'Developers embedding the SuperDoc editor in their own applications',
+ instructions:
+ 'packages/super-editor, packages/layout-engine, packages/word-layout, and packages/preset-geometry are internal engine packages bundled directly into SuperDoc. Treat their changes as part of the editor itself, not as an external dependency.',
+ },
+ },
+ ];
const config = {
branches,
@@ -120,13 +132,12 @@ if (!isLocalPreview) {
}
// GitHub plugin comes last
-if (!isLocalPreview) {
+if (shouldPublishGitHubRelease) {
config.plugins.push([
'@semantic-release/github',
{
successComment:
':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **superdoc** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
},
]);
}
diff --git a/packages/template-builder/.releaserc.cjs b/packages/template-builder/.releaserc.cjs
index e1d61522a5..1230f8c674 100644
--- a/packages/template-builder/.releaserc.cjs
+++ b/packages/template-builder/.releaserc.cjs
@@ -22,12 +22,10 @@ const branches = [
const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);
-// stable -> main syncs (real merges) re-attribute prereleases to PRs already shipped on @latest.
-// Gate per-PR/issue success comments off on prereleases to avoid duplicate "shipped" comments.
-const shouldCommentOnRelease = !isPrerelease;
-// Linear release comments are the shipped-version breadcrumb inside Linear
-// itself, so keep them on for prereleases even while GitHub PR comments stay
-// gated separately.
+// GitHub Releases are stable-only; prerelease tags and package publishing still proceed.
+const shouldPublishGitHubRelease = Boolean(branch) && !isPrerelease;
+// Linear release comments remain the shipped-version breadcrumb, so
+// prereleases link to their Git tags when no GitHub Release exists.
const shouldCommentOnLinearRelease = true;
// Use AI-powered notes for stable releases, conventional generator for prereleases
@@ -65,13 +63,14 @@ config.plugins.push([
},
]);
-config.plugins.push([
- '@semantic-release/github',
- {
- successComment:
- ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **template-builder** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
- successCommentCondition: shouldCommentOnRelease ? undefined : false,
- },
-]);
+if (shouldPublishGitHubRelease) {
+ config.plugins.push([
+ '@semantic-release/github',
+ {
+ successComment:
+ ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **template-builder** v${nextRelease.version}\n\nThe release is available on [GitHub release](https://github.com/superdoc-dev/superdoc/releases/tag/${nextRelease.gitTag})',
+ },
+ ]);
+}
module.exports = config;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5cb8da1114..61a090fd6d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -238,8 +238,8 @@ catalogs:
specifier: ^24.2.7
version: 24.2.9
semantic-release-ai-notes:
- specifier: ^0.2.3
- version: 0.2.3
+ specifier: ^0.4.0
+ version: 0.4.0
semantic-release-commit-filter:
specifier: ^1.0.2
version: 1.0.2
@@ -413,7 +413,7 @@ importers:
version: 24.2.9(typescript@5.9.3)
semantic-release-ai-notes:
specifier: 'catalog:'
- version: 0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6)
+ version: 0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6)
semantic-release-commit-filter:
specifier: 'catalog:'
version: 1.0.2
@@ -543,7 +543,7 @@ importers:
version: 24.2.9(typescript@5.9.3)
semantic-release-ai-notes:
specifier: 'catalog:'
- version: 0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6)
+ version: 0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6)
semantic-release-commit-filter:
specifier: 'catalog:'
version: 1.0.2
@@ -3780,21 +3780,6 @@ packages:
peerDependencies:
zod: ^4.0.0
- '@anthropic-ai/claude-agent-sdk@0.2.86':
- resolution: {integrity: sha512-Vynvs18smLzPSeSYk2/k/75IeiSa8AmrhkWgcwaVpZhLZOCuye5GYRv6uMZea0We/3N82c0udI5+hf2aOLiwMg==}
- engines: {node: '>=18.0.0'}
- peerDependencies:
- zod: ^4.0.0
-
- '@anthropic-ai/sdk@0.74.0':
- resolution: {integrity: sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==}
- hasBin: true
- peerDependencies:
- zod: ^3.25.0 || ^4.0.0
- peerDependenciesMeta:
- zod:
- optional: true
-
'@anthropic-ai/sdk@0.80.0':
resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==}
hasBin: true
@@ -20474,8 +20459,8 @@ packages:
resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==}
engines: {node: '>=18'}
- semantic-release-ai-notes@0.2.3:
- resolution: {integrity: sha512-xVcvNroQZbfKKlM3jepJFdxtMhzoXSnNQ6C37gNRUc20r15wlOe9nLj6bOroGFId5GIVhJU7wevFw7bEk8TzZw==}
+ semantic-release-ai-notes@0.4.0:
+ resolution: {integrity: sha512-4xqORLBAB8ILr3SkUcZTXyH6r1zgjlKrPokQEHOPC9hG9u7Dt+n2uOcEMnBNc0yQC6Ohphp30gFdjUKfQ8mz9w==}
engines: {node: '>=18'}
peerDependencies:
semantic-release: '>=20.0.0'
@@ -23510,31 +23495,6 @@ snapshots:
- '@cfworker/json-schema'
- supports-color
- '@anthropic-ai/claude-agent-sdk@0.2.86(zod@4.3.6)':
- dependencies:
- '@anthropic-ai/sdk': 0.74.0(zod@4.3.6)
- '@modelcontextprotocol/sdk': 1.28.0(zod@4.3.6)
- zod: 4.3.6
- optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
- transitivePeerDependencies:
- - '@cfworker/json-schema'
- - supports-color
-
- '@anthropic-ai/sdk@0.74.0(zod@4.3.6)':
- dependencies:
- json-schema-to-ts: 3.1.1
- optionalDependencies:
- zod: 4.3.6
-
'@anthropic-ai/sdk@0.80.0(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
@@ -45522,9 +45482,9 @@ snapshots:
'@peculiar/x509': 1.14.3
pkijs: 3.4.0
- semantic-release-ai-notes@0.2.3(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6):
+ semantic-release-ai-notes@0.4.0(semantic-release@24.2.9(typescript@5.9.3))(zod@4.3.6):
dependencies:
- '@anthropic-ai/claude-agent-sdk': 0.2.86(zod@4.3.6)
+ '@anthropic-ai/claude-agent-sdk': 0.2.105(zod@4.3.6)
'@semantic-release/error': 4.0.0
semantic-release: 24.2.9(typescript@5.9.3)
transitivePeerDependencies:
@@ -48222,7 +48182,7 @@ snapshots:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- postcss: 8.5.10
+ postcss: 8.5.14
rollup: 4.60.2
tinyglobby: 0.2.16
optionalDependencies:
@@ -48241,7 +48201,7 @@ snapshots:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- postcss: 8.5.10
+ postcss: 8.5.14
rollup: 4.60.2
tinyglobby: 0.2.16
optionalDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 63cf41b152..7b29e4dd91 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -107,7 +107,7 @@ catalog:
rollup-plugin-copy: ^3.5.0
rollup-plugin-visualizer: ^5.12.0
semantic-release: ^24.2.7
- semantic-release-ai-notes: ^0.2.3
+ semantic-release-ai-notes: ^0.4.0
semantic-release-commit-filter: ^1.0.2
sirv: ^3.0.2
tippy.js: ^6.3.7
diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs
index bd4ab3b30d..739840019b 100644
--- a/scripts/__tests__/release-local.test.mjs
+++ b/scripts/__tests__/release-local.test.mjs
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
@@ -32,6 +33,44 @@ function assertOrder(content, first, second, context) {
assert.ok(firstIndex < secondIndex, `${context}: expected "${first}" before "${second}"`);
}
+function normalizePyprojectReleaseArtifact(content) {
+ return spawnSync('python3', [path.join(REPO_ROOT, 'scripts/normalize-pyproject-release-artifact.py')], {
+ input: content,
+ encoding: 'utf8',
+ });
+}
+
+function extractShellFunction(content, functionName, context) {
+ const signature = `${functionName}() {`;
+ const start = content.indexOf(signature);
+ assert.notEqual(start, -1, `${context}: missing ${functionName}`);
+
+ const lineStart = content.lastIndexOf('\n', start) + 1;
+ const indentation = content.slice(lineStart, start);
+ const closing = `\n${indentation}}`;
+ const end = content.indexOf(closing, start);
+ assert.notEqual(end, -1, `${context}: missing closing brace for ${functionName}`);
+
+ return {
+ block: content.slice(start, end + closing.length),
+ end: end + closing.length,
+ };
+}
+
+function extractConflictResolutionLoop(content, startAt, context) {
+ const startMarker = 'while read -r f; do';
+ const endMarker = 'done < <(git diff --name-only --diff-filter=U)';
+ const start = content.indexOf(startMarker, startAt);
+ const end = content.indexOf(endMarker, start);
+ assert.notEqual(start, -1, `${context}: missing conflict-resolution loop`);
+ assert.notEqual(end, -1, `${context}: missing conflict-resolution loop terminator`);
+ return content.slice(start, end + endMarker.length);
+}
+
+function countOccurrences(content, needle) {
+ return content.split(needle).length - 1;
+}
+
test('inferDryRunWouldRelease detects pending release previews', () => {
assert.equal(inferDryRunWouldRelease('[semantic-release] โบ โน The next release version is 1.2.3'), true);
assert.equal(inferDryRunWouldRelease('There are no relevant changes, so no new version is released.'), false);
@@ -152,13 +191,16 @@ test('root release:dry-run script uses the local preview helper', async () => {
test('superdoc releaserc uses preview mode to avoid AI notes and side-effect plugins', async () => {
const content = await readRepoFile('packages/superdoc/.releaserc.cjs');
+ // Collapse whitespace so this tolerates reformatting (e.g. prettier wrapping
+ // a long ternary across lines) while still checking the real condition.
+ const normalized = content.replace(/\s+/g, ' ');
assert.ok(
content.includes("const isLocalPreview = process.env.SUPERDOC_RELEASE_PREVIEW === '1'"),
'packages/superdoc/.releaserc.cjs: must detect local preview mode',
);
assert.ok(
- content.includes('const notesPlugin =') &&
- content.includes('isLocalPreview || isPrerelease ? createReleaseNotesGenerator()'),
+ normalized.includes('const notesPlugin =') &&
+ normalized.includes('isLocalPreview || isPrerelease ? createReleaseNotesGenerator()'),
'packages/superdoc/.releaserc.cjs: preview mode must fall back to conventional release notes',
);
assert.ok(
@@ -187,7 +229,12 @@ test('stable orchestrator releases tools chain (CLI, SDK, MCP) and core chain (f
assertOrder(content, "name: 'sdk'", "name: 'mcp'", 'scripts/release-local-stable.mjs (sdk before mcp)');
assertOrder(content, "name: 'fonts'", "name: 'superdoc'", 'scripts/release-local-stable.mjs (fonts before superdoc)');
assertOrder(content, "name: 'superdoc'", "name: 'react'", 'scripts/release-local-stable.mjs (superdoc before react)');
- assertOrder(content, "name: 'react'", "name: 'vscode-ext'", 'scripts/release-local-stable.mjs (react before vscode-ext)');
+ assertOrder(
+ content,
+ "name: 'react'",
+ "name: 'vscode-ext'",
+ 'scripts/release-local-stable.mjs (react before vscode-ext)',
+ );
assert.ok(
content.includes("name: 'fonts'") && content.includes('npmPackages: FONTS_NPM_PACKAGES'),
'scripts/release-local-stable.mjs: orchestrator must release @superdoc-dev/fonts before superdoc points users at it',
@@ -221,12 +268,7 @@ test('fonts release workflow refuses automated releases before the 0.1.0 bootstr
workflow.includes('npm view @superdoc-dev/fonts@0.1.0 version'),
'.github/workflows/release-fonts.yml: must require the 0.1.0 npm package before semantic-release runs',
);
- assertOrder(
- workflow,
- 'Verify 0.1.0 bootstrap',
- 'pnpx semantic-release',
- '.github/workflows/release-fonts.yml',
- );
+ assertOrder(workflow, 'Verify 0.1.0 bootstrap', 'pnpx semantic-release', '.github/workflows/release-fonts.yml');
});
test('stable tooling bundle emits SDK release coordinates so Python publish does not depend on HEAD', async () => {
@@ -380,7 +422,9 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag
`${file}: refresh step must not be gated on stable-only; queued main runs also need to refresh to avoid stale-checkout pushes`,
);
assert.ok(
- /git fetch origin "\$\{\{ github\.ref_name \}\}" --tags\s*\n\s*git checkout -B "\$\{\{ github\.ref_name \}\}" "origin\/\$\{\{ github\.ref_name \}\}"/.test(content) ||
+ /git fetch origin "\$\{\{ github\.ref_name \}\}" --tags\s*\n\s*git checkout -B "\$\{\{ github\.ref_name \}\}" "origin\/\$\{\{ github\.ref_name \}\}"/.test(
+ content,
+ ) ||
// release-stable.yml is stable-only and refreshes against the literal `stable` ref.
/git fetch origin stable --tags\s*\n\s*git checkout -B stable origin\/stable/.test(content),
`${file}: must refresh to the current branch head before semantic-release runs`,
@@ -388,15 +432,26 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag
}
});
-test('stable-to-main sync waits for stable release completion', async () => {
+test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => {
const workflow = await readRepoFile('.github/workflows/sync-patches.yml');
+ const laneDrainStart = workflow.indexOf('- name: Wait for stable release lane to drain');
+ const laneDrainEnd = workflow.indexOf('- name: Generate token');
+
+ assert.notEqual(laneDrainStart, -1, '.github/workflows/sync-patches.yml: must include the stable lane drain step');
+ assert.notEqual(laneDrainEnd, -1, '.github/workflows/sync-patches.yml: must include the token generation step');
+ assert.ok(
+ laneDrainStart < laneDrainEnd,
+ '.github/workflows/sync-patches.yml: must drain the stable lane before checkout',
+ );
+
+ const laneDrainStep = workflow.slice(laneDrainStart, laneDrainEnd);
assert.ok(
workflow.includes('workflow_run:'),
'.github/workflows/sync-patches.yml: must trigger from release workflow completion, not directly from stable pushes',
);
assert.ok(
- /workflows:\s*\n\s*-\s*"๐ฆ Release stable tooling \(CLI\/SDK\/MCP\)"/.test(workflow),
+ /workflows:\s*\n\s*-\s*['"]๐ฆ Release stable tooling \(CLI\/SDK\/MCP\)['"]/.test(workflow),
'.github/workflows/sync-patches.yml: must trigger after the stable release orchestrator completes',
);
assert.equal(
@@ -409,9 +464,17 @@ test('stable-to-main sync waits for stable release completion', async () => {
'.github/workflows/sync-patches.yml: must scope automatic syncs to stable release runs',
);
assert.ok(
- workflow.includes("github.event.workflow_run.conclusion == 'success'") &&
- workflow.includes("github.event.workflow_run.conclusion == 'failure'"),
- '.github/workflows/sync-patches.yml: must wait for release completion while still surfacing failed-release sync PRs for review',
+ workflow.includes("github.event.workflow_run.conclusion == 'success'"),
+ '.github/workflows/sync-patches.yml: automatic syncs must require a successful stable tooling release',
+ );
+ assert.equal(
+ workflow.includes("github.event.workflow_run.conclusion == 'failure'"),
+ false,
+ '.github/workflows/sync-patches.yml: failed stable tooling releases must not push to main automatically',
+ );
+ assert.ok(
+ workflow.includes("github.event_name == 'workflow_dispatch'"),
+ '.github/workflows/sync-patches.yml: manual dispatch must remain available for investigated recovery',
);
assert.ok(
workflow.includes('actions: read'),
@@ -423,10 +486,41 @@ test('stable-to-main sync waits for stable release completion', async () => {
workflow.includes('"๐ฆ Release template-builder"'),
'.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable',
);
+ assert.equal(
+ laneDrainStep.includes("if: github.event_name == 'workflow_run'"),
+ false,
+ '.github/workflows/sync-patches.yml: manual recovery must also wait for active stable release runs to finish',
+ );
+ for (const removedGate of [
+ 'Verify stable release lane succeeded',
+ 'id: verify_release_lane',
+ 'require_successful_release()',
+ 'steps.verify_release_lane.outputs.stable_sha',
+ ]) {
+ assert.equal(
+ workflow.includes(removedGate),
+ false,
+ `.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`,
+ );
+ }
});
test('stable-to-main sync preserves stable release ancestry', async () => {
const workflow = await readRepoFile('.github/workflows/sync-patches.yml');
+ const artifactConflictFunction = extractShellFunction(
+ workflow,
+ 'release_artifact_only_conflict',
+ '.github/workflows/sync-patches.yml',
+ );
+ const conflictResolutionLoop = extractConflictResolutionLoop(
+ workflow,
+ artifactConflictFunction.end,
+ '.github/workflows/sync-patches.yml',
+ );
+ const stableShaAssignment = 'stable_sha=$(git rev-parse origin/stable)';
+ const pinIndex = workflow.indexOf(stableShaAssignment);
+ const verificationIndex = workflow.indexOf('git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"');
+ const mergeIndex = workflow.indexOf('git merge --no-ff --no-edit "$stable_sha"');
assert.equal(
workflow.includes('git merge --squash'),
@@ -434,31 +528,145 @@ test('stable-to-main sync preserves stable release ancestry', async () => {
'.github/workflows/sync-patches.yml: stable-to-main sync must not squash because semantic-release needs stable tags reachable from main',
);
assert.ok(
- workflow.includes('git merge --no-ff --no-edit origin/stable'),
- '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit',
- );
- assert.ok(
- workflow.includes('git merge-base --is-ancestor origin/stable origin/main') &&
- workflow.includes('git merge-base --is-ancestor origin/stable HEAD'),
- '.github/workflows/sync-patches.yml: sync must guard on and verify stable ancestry',
- );
- assert.ok(
- workflow.includes('release_artifact_only_conflict') &&
- workflow.includes('version-only') &&
- workflow.includes('git checkout --theirs "$f"'),
+ mergeIndex !== -1,
+ '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the pinned stable SHA',
+ );
+ assert.ok(
+ pinIndex !== -1 &&
+ (workflow.match(/stable_sha=\$\(git rev-parse origin\/stable\)/g) ?? []).length === 1 &&
+ workflow.indexOf('git fetch origin main stable --tags --prune') < pinIndex &&
+ pinIndex < mergeIndex &&
+ !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('git fetch origin') &&
+ !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('origin/stable') &&
+ pinIndex < verificationIndex &&
+ verificationIndex < mergeIndex &&
+ workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') &&
+ workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') &&
+ workflow.includes('semantic-release-bot@martynus.net') &&
+ workflow.includes('release_subject_pattern=') &&
+ workflow.includes('git diff-tree --no-commit-id --name-only -r "$sha"') &&
+ workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') &&
+ workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'),
+ '.github/workflows/sync-patches.yml: must verify release writeback provenance and files before merging the pinned stable SHA',
+ );
+ assert.ok(
+ workflow.includes('version-only') &&
+ conflictResolutionLoop.includes('release_artifact_only_conflict "$f"') &&
+ conflictResolutionLoop.includes('git checkout --theirs "$f"') &&
+ countOccurrences(artifactConflictFunction.block, 'python3 scripts/normalize-pyproject-release-artifact.py') === 2,
'.github/workflows/sync-patches.yml: release artifact conflicts must resolve to stable only when the conflict is version-only',
);
assert.equal(
workflow.includes('git add -A'),
false,
- '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers into review PRs',
+ '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers',
);
assert.ok(
- workflow.includes("This PR must be merged with GitHub's merge-commit option"),
- '.github/workflows/sync-patches.yml: generated PRs must warn reviewers not to squash away stable ancestry',
+ workflow.includes('git push origin HEAD:main'),
+ '.github/workflows/sync-patches.yml: sync must push the verified merge commit directly so stable ancestry is preserved',
);
});
+test('pyproject release normalization accepts coordinated Python SDK platform pins', () => {
+ const pyproject = (version) => `[project]
+name = "superdoc-sdk"
+version = "${version}"
+dependencies = [
+ "superdoc-sdk-cli-darwin-arm64==${version}; platform_system == 'Darwin'",
+ "superdoc-sdk-cli-darwin-x64==${version}; platform_system == 'Darwin'",
+ "superdoc-sdk-cli-linux-arm64==${version}; platform_system == 'Linux'",
+ "superdoc-sdk-cli-linux-x64==${version}; platform_system == 'Linux'",
+ "superdoc-sdk-cli-windows-x64==${version}; platform_system == 'Windows'",
+]
+`;
+
+ const main = normalizePyprojectReleaseArtifact(pyproject('1.20.1'));
+ const stable = normalizePyprojectReleaseArtifact(pyproject('1.20.2'));
+
+ assert.equal(main.status, 0, main.stderr);
+ assert.equal(stable.status, 0, stable.stderr);
+ assert.equal(main.stdout, stable.stdout);
+});
+
+test('pyproject release normalization preserves real dependency changes', () => {
+ const main = normalizePyprojectReleaseArtifact(`[project]
+version = "1.20.1"
+dependencies = ["httpx>=0.27"]
+`);
+ const stable = normalizePyprojectReleaseArtifact(`[project]
+version = "1.20.2"
+dependencies = ["httpx>=0.28"]
+`);
+
+ assert.equal(main.status, 0, main.stderr);
+ assert.equal(stable.status, 0, stable.stderr);
+ assert.notEqual(main.stdout, stable.stdout);
+});
+
+test('pyproject release normalization rejects a platform pin that diverges from the project version', () => {
+ const result = normalizePyprojectReleaseArtifact(`[project]
+version = "1.20.2"
+dependencies = ["superdoc-sdk-cli-linux-x64==1.20.1; platform_system == 'Linux'"]
+`);
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /does not match project version 1\.20\.2/);
+});
+
+test('pyproject release normalization ignores embedded package names and comments', () => {
+ const result = normalizePyprojectReleaseArtifact(`[project]
+version = "1.20.2"
+dependencies = [
+ "internal-superdoc-sdk-cli-linux-x64==0.5.0",
+]
+# superdoc-sdk-cli-linux-x64==0.5.0
+# "superdoc-sdk-cli-windows-x64==0.5.0"
+`);
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /internal-superdoc-sdk-cli-linux-x64==0\.5\.0/);
+ assert.match(result.stdout, /# superdoc-sdk-cli-linux-x64==0\.5\.0/);
+ assert.match(result.stdout, /# "superdoc-sdk-cli-windows-x64==0\.5\.0"/);
+});
+
+test('pyproject release normalization distinguishes URL fragments from TOML comments', () => {
+ const pyproject = (pinVersion) => `[project]
+version = "1.20.2"
+dependencies = ["custom @ https://example.invalid/pkg.whl#fragment", "superdoc-sdk-cli-linux-x64==${pinVersion}; platform_system == 'Linux'"]
+`;
+
+ const valid = normalizePyprojectReleaseArtifact(pyproject('1.20.2'));
+ const mismatched = normalizePyprojectReleaseArtifact(pyproject('1.20.1'));
+
+ assert.equal(valid.status, 0, valid.stderr);
+ assert.match(valid.stdout, /pkg\.whl#fragment/);
+ assert.match(valid.stdout, /superdoc-sdk-cli-linux-x64==/);
+ assert.notEqual(mismatched.status, 0);
+});
+
+test('both stable sync directions use checked pyproject release normalization', async () => {
+ const workflows = [
+ ['.github/workflows/sync-patches.yml', 'git checkout --theirs "$f"'],
+ ['.github/workflows/promote-stable.yml', 'git checkout --ours "$f"'],
+ ];
+
+ for (const [workflowPath, expectedCheckout] of workflows) {
+ const workflow = await readRepoFile(workflowPath);
+ const artifactConflictFunction = extractShellFunction(workflow, 'release_artifact_only_conflict', workflowPath);
+ const conflictResolutionLoop = extractConflictResolutionLoop(workflow, artifactConflictFunction.end, workflowPath);
+
+ assert.equal(
+ countOccurrences(artifactConflictFunction.block, 'python3 scripts/normalize-pyproject-release-artifact.py'),
+ 2,
+ `${workflowPath}: both pyproject conflict stages must use checked release normalization`,
+ );
+ assert.ok(
+ conflictResolutionLoop.includes(expectedCheckout),
+ `${workflowPath}: conflict resolution uses the wrong side`,
+ );
+ }
+});
+
test('MCP releaserc builds the package before publish so the tarball ships dist/', async () => {
const content = await readRepoFile('apps/mcp/.releaserc.cjs');
assert.ok(
@@ -470,11 +678,12 @@ test('MCP releaserc builds the package before publish so the tarball ships dist/
test('stable recovery filters prerelease tags so *-next.* never resumes as @latest', async () => {
const content = await readRepoFile('scripts/release-local-stable.mjs');
assert.ok(
- content.includes('listStableMergedTags') && content.includes("isPrereleaseTag"),
+ content.includes('listStableMergedTags') && content.includes('isPrereleaseTag'),
'scripts/release-local-stable.mjs: must expose a stable-only tag filter that excludes -next.* prereleases',
);
assert.ok(
- content.includes("expectedBranch === 'stable'") && content.includes('listStableMergedTags(pkg.tagPattern, branchRef)'),
+ content.includes("expectedBranch === 'stable'") &&
+ content.includes('listStableMergedTags(pkg.tagPattern, branchRef)'),
'scripts/release-local-stable.mjs: stable recovery must consult the prerelease-filtered list',
);
});
@@ -531,16 +740,20 @@ test('stable recovery tracks PyPI gaps when SDK PyPI publishing is enabled', asy
'scripts/release-local-stable.mjs: must keep the SDK PyPI enabled state next to the recovery decision',
);
assert.ok(
- /name: 'sdk'[\s\S]*\.\.\.\(SDK_PYPI_ENABLED[\s\S]*pythonPackages: SDK_PYTHON_PACKAGES[\s\S]*preparePythonSnapshot: prepareSdkPythonSnapshot/.test(content),
+ /name: 'sdk'[\s\S]*\.\.\.\(SDK_PYPI_ENABLED[\s\S]*pythonPackages: SDK_PYTHON_PACKAGES[\s\S]*preparePythonSnapshot: prepareSdkPythonSnapshot/.test(
+ content,
+ ),
'scripts/release-local-stable.mjs: SDK Python tracking and snapshot recovery must move together behind SDK_PYPI_ENABLED',
);
assert.ok(
- /function prepareSdkPythonSnapshot[\s\S]*pnpm'[\s\S]*'run'[\s\S]*'generate:all'[\s\S]*build-python-sdk\.mjs/.test(content),
+ /function prepareSdkPythonSnapshot[\s\S]*pnpm'[\s\S]*'run'[\s\S]*'generate:all'[\s\S]*build-python-sdk\.mjs/.test(
+ content,
+ ),
'scripts/release-local-stable.mjs: recovered SDK Python snapshots must regenerate artifacts before building wheels',
);
});
-test('release configs keep GitHub prerelease comments gated while Linear uses the dedicated release-comment policy', async () => {
+test('release configs publish GitHub releases only for stable versions while Linear keeps prerelease breadcrumbs', async () => {
const releasercPaths = [
'packages/superdoc/.releaserc.cjs',
'packages/fonts/.releaserc.cjs',
@@ -568,15 +781,21 @@ test('release configs keep GitHub prerelease comments gated while Linear uses th
`${releasercPath}: must use the commit-message Linear sync plugin, not the PR-branch-based external plugin`,
);
- assert.ok(
- content.includes('const shouldCommentOnRelease = !isPrerelease'),
- `${releasercPath}: must define shouldCommentOnRelease = !isPrerelease so the prerelease comment gate is consistent across configs`,
- );
-
if (usesGithubPlugin) {
assert.ok(
- content.includes('successCommentCondition: shouldCommentOnRelease ? undefined : false'),
- `${releasercPath}: @semantic-release/github must gate successCommentCondition through shouldCommentOnRelease so prereleases don't re-comment on every PR after a stable -> main sync`,
+ content.includes('const shouldPublishGitHubRelease =') &&
+ content.includes('Boolean(branch)') &&
+ content.includes('!isPrerelease'),
+ `${releasercPath}: must fail closed when the release branch is unknown and publish GitHub releases only for stable versions`,
+ );
+ assert.ok(
+ content.includes('if (shouldPublishGitHubRelease)'),
+ `${releasercPath}: must gate @semantic-release/github behind shouldPublishGitHubRelease`,
+ );
+ assert.equal(
+ content.includes('successCommentCondition:'),
+ false,
+ `${releasercPath}: prereleases must omit the GitHub plugin instead of configuring its success hook`,
);
}
@@ -587,7 +806,7 @@ test('release configs keep GitHub prerelease comments gated while Linear uses th
);
assert.ok(
content.includes('addComment: shouldCommentOnLinearRelease'),
- `${releasercPath}: Linear addComment must use the dedicated Linear comment gate so prerelease Linear breadcrumbs stay on while GitHub PR comments remain gated`,
+ `${releasercPath}: Linear addComment must use the dedicated Linear comment gate so prerelease breadcrumbs remain available without GitHub Releases`,
);
assert.equal(
content.includes('addComment: true'),
@@ -635,7 +854,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run',
'.github/workflows/promote-stable-docs.yml: must trigger off the stable orchestrator workflow',
);
assert.equal(
- /"๐ฆ Release CLI"|"๐ฆ Release SDK"|"๐ฆ Release MCP"|"๐ฆ Release react"|"๐ฆ Release esign"|"๐ฆ Release template-builder"|"๐ฆ Release vscode-ext"/.test(workflowRunBlock),
+ /"๐ฆ Release CLI"|"๐ฆ Release SDK"|"๐ฆ Release MCP"|"๐ฆ Release react"|"๐ฆ Release esign"|"๐ฆ Release template-builder"|"๐ฆ Release vscode-ext"/.test(
+ workflowRunBlock,
+ ),
false,
'.github/workflows/promote-stable-docs.yml: must trigger only off the orchestrator, not per-package workflows',
);
@@ -653,7 +874,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run',
assert.ok(
promoteWorkflow.includes('Wait for stable release lane to drain') &&
promoteWorkflow.includes('gh run list') &&
- promoteWorkflow.includes('"๐ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐ฆ Release esign" or .name == "๐ฆ Release template-builder"'),
+ promoteWorkflow.includes(
+ '"๐ฆ Release stable tooling (CLI/SDK/MCP)" or .name == "๐ฆ Release esign" or .name == "๐ฆ Release template-builder"',
+ ),
'.github/workflows/promote-stable-docs.yml: must wait for the stable release lane to drain before inspecting origin/stable',
);
assert.ok(
@@ -678,7 +901,9 @@ test('docs promotion is keyed to a real superdoc tag from the orchestrator run',
'.github/workflows/promote-stable-docs.yml: must refuse to overwrite docs commits that exist only on docs-stable',
);
assert.ok(
- promoteWorkflow.includes('git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable"'),
+ promoteWorkflow.includes(
+ 'git push --force-with-lease=refs/heads/docs-stable:"${expected}" origin "${target}:refs/heads/docs-stable"',
+ ),
'.github/workflows/promote-stable-docs.yml: must use force-with-lease when promoting the docs-stable pointer',
);
});
diff --git a/scripts/normalize-pyproject-release-artifact.py b/scripts/normalize-pyproject-release-artifact.py
new file mode 100644
index 0000000000..7cb9ced53a
--- /dev/null
+++ b/scripts/normalize-pyproject-release-artifact.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+
+import re
+import sys
+
+
+PROJECT_VERSION = re.compile(
+ r"(?m)^([ \t]*version[ \t]*=[ \t]*)([\"'])([^\"'\r\n]+)\2([ \t]*(?:#.*)?(?:\r?\n|$))"
+)
+SDK_PLATFORM_PIN = re.compile(
+ r"([\"'])(superdoc-sdk-cli-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)|windows-x64))(==)([^;\"'\s]+)"
+)
+NORMALIZED_VERSION = ""
+
+
+def fail(message: str) -> None:
+ print(message, file=sys.stderr)
+ raise SystemExit(1)
+
+
+content = sys.stdin.read()
+version_matches = list(PROJECT_VERSION.finditer(content))
+
+if len(version_matches) != 1:
+ fail(f"expected exactly one project version, found {len(version_matches)}")
+
+project_version = version_matches[0].group(3)
+
+
+def is_in_toml_comment(match: re.Match[str]) -> bool:
+ line_start = match.string.rfind("\n", 0, match.start()) + 1
+ quote = None
+ escaped = False
+
+ for character in match.string[line_start : match.start()]:
+ if quote == '"':
+ if escaped:
+ escaped = False
+ elif character == "\\":
+ escaped = True
+ elif character == quote:
+ quote = None
+ elif quote == "'":
+ if character == quote:
+ quote = None
+ elif character == "#":
+ return True
+ elif character in {'"', "'"}:
+ quote = character
+
+ return False
+
+
+for pin_match in SDK_PLATFORM_PIN.finditer(content):
+ if is_in_toml_comment(pin_match):
+ continue
+ if pin_match.group(4) != project_version:
+ fail(
+ f"{pin_match.group(2)} pin {pin_match.group(4)} "
+ f"does not match project version {project_version}"
+ )
+
+
+def normalize_project_version(match: re.Match[str]) -> str:
+ prefix, quote, _, suffix = match.groups()
+ return f"{prefix}{quote}{NORMALIZED_VERSION}{quote}{suffix}"
+
+
+def normalize_sdk_platform_pin(match: re.Match[str]) -> str:
+ if is_in_toml_comment(match):
+ return match.group(0)
+ quote, package_name, operator, _ = match.groups()
+ return f"{quote}{package_name}{operator}{NORMALIZED_VERSION}"
+
+
+normalized = PROJECT_VERSION.sub(normalize_project_version, content, count=1)
+normalized = SDK_PLATFORM_PIN.sub(normalize_sdk_platform_pin, normalized)
+sys.stdout.write(normalized)
diff --git a/scripts/semantic-release/linear-commit-sync.cjs b/scripts/semantic-release/linear-commit-sync.cjs
index 501101373e..efb5b86322 100644
--- a/scripts/semantic-release/linear-commit-sync.cjs
+++ b/scripts/semantic-release/linear-commit-sync.cjs
@@ -266,7 +266,7 @@ function getLabelColor(releaseType) {
return colors[releaseType] || '#4752C4';
}
-function buildReleaseUrl(repositoryUrl, gitTag) {
+function buildReleaseUrl(repositoryUrl, gitTag, channel) {
if (!repositoryUrl || !gitTag) {
return '';
}
@@ -274,13 +274,14 @@ function buildReleaseUrl(repositoryUrl, gitTag) {
if (!match) {
return '';
}
- return `https://github.com/${match[1]}/${match[2]}/releases/tag/${gitTag}`;
+ const tagPath = channel === 'next' ? 'tree' : 'releases/tag';
+ return `https://github.com/${match[1]}/${match[2]}/${tagPath}/${gitTag}`;
}
function formatComment(template, version, channel, packageName, gitTag, repositoryUrl) {
const channelText = channel ? `(${channel} channel)` : '';
const packageText = packageName ? `**${packageName}**` : '';
- const releaseUrl = buildReleaseUrl(repositoryUrl, gitTag);
+ const releaseUrl = buildReleaseUrl(repositoryUrl, gitTag, channel);
const releaseLink = releaseUrl ? `[${version}](${releaseUrl})` : version;
const tpl = template || 'Released in {package} v{releaseLink} {channel}';
return tpl
diff --git a/scripts/semantic-release/linear-commit-sync.test.cjs b/scripts/semantic-release/linear-commit-sync.test.cjs
index 02a17116d1..9cf8c28daa 100644
--- a/scripts/semantic-release/linear-commit-sync.test.cjs
+++ b/scripts/semantic-release/linear-commit-sync.test.cjs
@@ -89,7 +89,7 @@ test('collectIssueIdsFromCommits ignores generated release commits with old note
});
-test('formatComment keeps the existing release comment template behavior', () => {
+test('formatComment links prerelease comments to the Git tag', () => {
assert.equal(
formatComment(
'shipped in {package} {releaseLink} {channel}',
@@ -99,7 +99,21 @@ test('formatComment keeps the existing release comment template behavior', () =>
'v1.2.3',
'https://github.com/superdoc-dev/superdoc.git',
),
- 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/releases/tag/v1.2.3) (next channel)',
+ 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/tree/v1.2.3) (next channel)',
+ );
+});
+
+test('formatComment links stable comments to the GitHub release', () => {
+ assert.equal(
+ formatComment(
+ 'shipped in {package} {releaseLink} {channel}',
+ '1.2.3',
+ 'latest',
+ 'superdoc',
+ 'v1.2.3',
+ 'https://github.com/superdoc-dev/superdoc.git',
+ ),
+ 'shipped in **superdoc** [1.2.3](https://github.com/superdoc-dev/superdoc/releases/tag/v1.2.3) (latest channel)',
);
});