@typeroll/mcp-server 0.45.43 → 0.45.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +155 -43
- package/README.md +87 -23
- package/dist/bundled-content.js +9 -9
- package/dist/client.js +15 -1
- package/dist/compact-tools.js +1 -1
- package/dist/server.js +49 -2
- package/dist/tools/access.js +75 -0
- package/dist/tools/content-types.js +2 -2
- package/dist/tools/domain.js +33 -0
- package/dist/tools/email.js +69 -0
- package/dist/tools/extensions.js +131 -0
- package/dist/tools/forms.js +34 -4
- package/dist/tools/helpers.js +7 -0
- package/dist/tools/media.js +12 -0
- package/dist/tools/page-blocks.js +5 -33
- package/dist/tools/pages.js +11 -4
- package/dist/tools/partials.js +1 -1
- package/dist/tools/reusable-blocks.js +1 -1
- package/dist/tools/settings.js +20 -10
- package/dist/tools/sites.js +25 -3
- package/dist/tools/versions.js +21 -3
- package/dist/tools/workflows.js +61 -0
- package/dist/tools/working-copy.js +67 -3
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/tr-content-write.md +3 -3
- package/skills/tr-forms.md +36 -8
- package/skills/tr-import-url.md +2 -2
- package/skills/tr-migrate-wp.md +9 -5
- package/skills/tr-new-site.md +5 -4
- package/skills/tr-redesign-branch.md +15 -1
- package/skills/tr-seo.md +14 -0
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// Page-block mutation tools
|
|
2
|
-
//
|
|
3
|
-
// (override with `version`).
|
|
1
|
+
// Page-block mutation tools: get/add/update/move/remove and friends. All
|
|
2
|
+
// target the active version (override with `version`).
|
|
4
3
|
//
|
|
5
|
-
// The AI agent uses these to author block-mode pages directly.
|
|
6
|
-
// HTML
|
|
7
|
-
// It never writes. A person accepts that preview in the page editor.
|
|
4
|
+
// The AI agent uses these to author block-mode pages directly. Typeroll does
|
|
5
|
+
// not convert HTML into blocks; block pages are built with blocks.
|
|
8
6
|
import { z } from 'zod';
|
|
9
7
|
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
10
8
|
const blockSchema = z.object({
|
|
@@ -193,7 +191,7 @@ export const pageBlockTools = [
|
|
|
193
191
|
},
|
|
194
192
|
{
|
|
195
193
|
name: 'set_page_mode',
|
|
196
|
-
description: 'Safely switch a page\'s content_mode between "blocks" and "html". Always snapshots a revision before the flip so the previous state is restorable. HTML → blocks does not convert or rewrite the body. Blocks → HTML drops the block tree (the revision retains it). Prefer this over `update_page patch={content_mode}` because that bypasses snapshotting.
|
|
194
|
+
description: 'Safely switch a page\'s content_mode between "blocks" and "html". Always snapshots a revision before the flip so the previous state is restorable. HTML → blocks does not convert or rewrite the body. Blocks → HTML drops the block tree (the revision retains it). Prefer this over `update_page patch={content_mode}` because that bypasses snapshotting. Typeroll does not convert HTML into blocks: after switching to blocks, build the content with blocks.',
|
|
197
195
|
inputSchema: {
|
|
198
196
|
page_id: z.string(),
|
|
199
197
|
to: z.enum(['blocks', 'html']).describe('Target mode.'),
|
|
@@ -204,30 +202,4 @@ export const pageBlockTools = [
|
|
|
204
202
|
return ok(res);
|
|
205
203
|
}),
|
|
206
204
|
},
|
|
207
|
-
{
|
|
208
|
-
name: 'convert_page_to_blocks',
|
|
209
|
-
description: 'Preview a heuristic conversion of a page\'s html_content into a block tree. Always returns the proposal, including unconverted text, classes and markup, and does not write. A person accepts the preview in the page editor. This tool cannot change content_mode or replace the body.',
|
|
210
|
-
inputSchema: {
|
|
211
|
-
page_id: z.string(),
|
|
212
|
-
version: versionParam,
|
|
213
|
-
},
|
|
214
|
-
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
215
|
-
const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks/convert`, { dry_run: true }, v(args.version));
|
|
216
|
-
return ok(res);
|
|
217
|
-
}),
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
name: 'convert_prose_block',
|
|
221
|
-
description: 'Turn one text block (core/prose) into separate blocks: headings (a classed label above a heading becomes its eyebrow), paragraphs, lists, images and buttons. Use it when a text block holds headings, classed HTML or media that should be editable and styled with named styles. Call without `accept` to preview (nothing is written); review `converted`, `notes` and `unconverted`, then call again with `accept` set to the preview\'s `fingerprint`. Writes the page draft (working copy) like other block edits. Pages only.',
|
|
222
|
-
inputSchema: {
|
|
223
|
-
page_id: z.string(),
|
|
224
|
-
block_id: z.string(),
|
|
225
|
-
accept: z.string().optional().describe('The fingerprint from a preview of this same block. Omit to preview.'),
|
|
226
|
-
version: versionParam,
|
|
227
|
-
},
|
|
228
|
-
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
229
|
-
const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks/convert-prose`, { block_id: args.block_id, ...(args.accept ? { accept: args.accept } : {}) }, v(args.version));
|
|
230
|
-
return ok(res);
|
|
231
|
-
}),
|
|
232
|
-
},
|
|
233
205
|
];
|
package/dist/tools/pages.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Pages tools. Read + write + batch + blocks-view + preview.
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
import { ok, withErrorBoundary, versionParam, writeAuthority } from './helpers.js';
|
|
4
4
|
const answerSources = z.record(z.object({ source_url: z.string().url().optional(), import_run_id: z.string().max(200).optional() }).strict()).optional().describe('Evidence per schema leaf path. Actor and timestamp are always assigned by the server; unchanged answers are not confirmed.');
|
|
5
|
+
function authorityBody(args) {
|
|
6
|
+
return args.override_reason ? { override_reason: args.override_reason } : {};
|
|
7
|
+
}
|
|
5
8
|
function v(version) {
|
|
6
9
|
return version ? { version } : undefined;
|
|
7
10
|
}
|
|
@@ -108,6 +111,7 @@ export const pageTools = [
|
|
|
108
111
|
template: z.string().nullable().optional().describe('Allowed Page template ID; null uses the content type default.'),
|
|
109
112
|
image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching.'),
|
|
110
113
|
custom_css: z.string().optional().describe('Per-page CSS, injected into <head> as a <style> AFTER the site-level custom_css (so it overrides site styling). This is the RIGHT home for page-specific styling — page metadata, not content. Put a page\'s <style> here instead of stuffing it into a core/html block (which is opaque and un-editable in the visual editor). Syntax errors refuse the write; target s-<style> classes or block custom_class rather than [data-block]/.block-* platform markup.'),
|
|
114
|
+
...writeAuthority,
|
|
111
115
|
version: versionParam,
|
|
112
116
|
},
|
|
113
117
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
@@ -168,11 +172,12 @@ export const pageTools = [
|
|
|
168
172
|
})
|
|
169
173
|
.passthrough(),
|
|
170
174
|
answer_sources: answerSources,
|
|
175
|
+
...writeAuthority,
|
|
171
176
|
save: z.boolean().optional().describe('Also SAVE (commit) the draft in the same call — use for pre-approved or batch changes. Without it, changes stay in the unsaved draft until commit_working_copy.'),
|
|
172
177
|
version: versionParam,
|
|
173
178
|
},
|
|
174
179
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
175
|
-
const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.patch, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...(args.save ? { save: true } : {}) }, v(args.version));
|
|
180
|
+
const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.patch, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...authorityBody(args), ...(args.save ? { save: true } : {}) }, v(args.version));
|
|
176
181
|
return ok(res);
|
|
177
182
|
}),
|
|
178
183
|
},
|
|
@@ -183,11 +188,12 @@ export const pageTools = [
|
|
|
183
188
|
page_id: z.string(),
|
|
184
189
|
page: z.object({ title: z.string().min(1) }).passthrough(),
|
|
185
190
|
answer_sources: answerSources,
|
|
191
|
+
...writeAuthority,
|
|
186
192
|
save: z.boolean().optional().describe('Also SAVE (commit) the draft in the same call — use for pre-approved or batch changes. Without it, changes stay in the unsaved draft until commit_working_copy.'),
|
|
187
193
|
version: versionParam,
|
|
188
194
|
},
|
|
189
195
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
190
|
-
const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.page, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...(args.save ? { save: true } : {}) }, v(args.version));
|
|
196
|
+
const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.page, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...authorityBody(args), ...(args.save ? { save: true } : {}) }, v(args.version));
|
|
191
197
|
return ok(res);
|
|
192
198
|
}),
|
|
193
199
|
},
|
|
@@ -201,6 +207,7 @@ export const pageTools = [
|
|
|
201
207
|
patch: z.record(z.unknown()),
|
|
202
208
|
save: z.boolean().optional(),
|
|
203
209
|
answer_sources: answerSources,
|
|
210
|
+
...writeAuthority,
|
|
204
211
|
}))
|
|
205
212
|
.min(1)
|
|
206
213
|
.max(200),
|
|
@@ -280,7 +287,7 @@ export const pageTools = [
|
|
|
280
287
|
// the block-tree mutation tools (add/update/move/remove/convert).
|
|
281
288
|
{
|
|
282
289
|
name: 'get_page_preview',
|
|
283
|
-
description: "Get the WHOLE page rendered as one self-contained HTML document — header partial + block-rendered body + footer partial, with the site's settings CSS variables, global styles,
|
|
290
|
+
description: "Get the WHOLE page rendered as one self-contained HTML document — header partial + block-rendered body + footer partial, with the site's settings CSS variables, global styles, the tree-shaken block-CSS bundle, block JavaScript, the Extension runtime and the cookie-consent banner all included, exactly as the portal Preview shows it. This is the single artifact for UNDERSTANDING what a page looks like and how its CSS actually cascades (get_page_blocks gives the editable block tree; this gives the rendered result). Returns { rendered_html, internal_links[] }. Pass annotate:true to tag every element with data-block-id + data-block-type so you can map the rendered HTML back to the block to edit. For a clickable preview URL use get_preview_link instead.",
|
|
284
291
|
inputSchema: {
|
|
285
292
|
page_id: z.string(),
|
|
286
293
|
annotate: z
|
package/dist/tools/partials.js
CHANGED
|
@@ -118,7 +118,7 @@ export const partialTools = [
|
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
name: 'find_pages_using_block',
|
|
121
|
-
description: '
|
|
121
|
+
description: 'What references a global block, saved or in a draft: `pages` (core/global_block or <x-include name="…">), `templates` (page templates) and `global_blocks` (header, footer and other global blocks that nest it; a header or footer means every page). For partial_id "header" or "footer" returns the full page list (they are auto-injected on every page). Use this to know the blast radius before editing a shared block.',
|
|
122
122
|
inputSchema: {
|
|
123
123
|
partial_id: z.string(),
|
|
124
124
|
version: versionParam,
|
|
@@ -24,7 +24,7 @@ export const reusableBlockTools = [
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
name: 'detach_global_block',
|
|
27
|
-
description: 'Replace a core/global_block reference on a page with an editable copy of the global block\'s blocks. Later edits to the global block no longer reach this page. Writes the page draft.',
|
|
27
|
+
description: 'Replace a core/global_block reference on a page with an editable copy of the global block\'s blocks. Copies the global block\'s unsaved draft on this version when it has one (what the editors show), otherwise the saved global block; `copied_from` says which. Later edits to the global block no longer reach this page. Writes the page draft.',
|
|
28
28
|
inputSchema: {
|
|
29
29
|
page_id: z.string(),
|
|
30
30
|
block_id: z.string().describe('The id of the core/global_block reference block.'),
|
package/dist/tools/settings.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Settings tools.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// Settings tools. update_site_settings accepts every field the portal
|
|
2
|
+
// Settings form does — including scripts_head, scripts_body_end,
|
|
3
|
+
// custom_css, cookie-consent scripts, default_og_image, twitter_handle,
|
|
4
|
+
// organization (JSON-LD) and staging_url — with the same admin permission
|
|
5
|
+
// the portal requires. A bearer-token caller takes responsibility for what
|
|
6
|
+
// it ships, exactly as a site admin in the portal does. (The in-portal chat
|
|
7
|
+
// assistant has its own, narrower settings tool; that is unrelated to MCP.)
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
10
10
|
function v(version) {
|
|
@@ -13,7 +13,7 @@ function v(version) {
|
|
|
13
13
|
export const settingsTools = [
|
|
14
14
|
{
|
|
15
15
|
name: 'read_site_settings',
|
|
16
|
-
description: "Read every site setting: name, tagline, logo, favicon/app icons, colors, fonts, contact info, social links, URL trailing-slash policy, iframe host allowlist, default SEO suffix/description, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css, and `render` (the site's platform render version, the latest version and what upgrading would change). Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
|
|
16
|
+
description: "Read every site setting: name, tagline, logo, favicon/app icons, colors, fonts, contact info, social links, URL trailing-slash policy, iframe host allowlist, default SEO suffix/description, default_og_image, twitter_handle, organization (JSON-LD), language, robots_txt, image_sizes_default, cookie_consent, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css, `urls` (including `urls.staging`, the site's staging_url) and `render` (the site's platform render version, the latest version and what upgrading would change). Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
version: versionParam,
|
|
19
19
|
},
|
|
@@ -24,12 +24,14 @@ export const settingsTools = [
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
name: 'update_site_settings',
|
|
27
|
-
description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Only the fields you pass change. ' +
|
|
27
|
+
description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Accepts everything the portal Settings form does. Requires admin permission on the site, as in the portal. Only the fields you pass change. ' +
|
|
28
28
|
'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
|
|
29
29
|
'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
|
|
30
30
|
'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
|
|
31
31
|
'Unknown top-level keys return a 400 error listing the valid fields. ' +
|
|
32
|
-
'scripts_head / scripts_body_end / custom_css ARE writable here — useful for a global stylesheet across all pages. ' +
|
|
32
|
+
'scripts_head / scripts_body_end / custom_css and cookie_consent scripts ARE writable here — useful for a global stylesheet or analytics tag across all pages. ' +
|
|
33
|
+
'Social sharing and structured data: default_og_image, twitter_handle, organization ({name, logo, same_as[]}, replaced as a whole; null clears). ' +
|
|
34
|
+
'staging_url is stored on the Site (not per version), so it applies whichever `version` you pass. ' +
|
|
33
35
|
'Pass `version` to scope the write to a branch (copy-on-write) instead of main — the right way to brand/recolor a site inside a redesign branch (colors, fonts, logo, custom_css) without touching the live settings. Omit it to write main.',
|
|
34
36
|
inputSchema: {
|
|
35
37
|
version: versionParam,
|
|
@@ -54,6 +56,14 @@ export const settingsTools = [
|
|
|
54
56
|
scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page. Use for analytics, fonts, third-party CSS links.'),
|
|
55
57
|
scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page. Use for chat widgets, deferred analytics.'),
|
|
56
58
|
custom_css: z.string().optional().describe('Global CSS in a <style> in <head>, after block CSS and before the page\'s own custom CSS and the template base stylesheet (reset + global). Base rules for :root tokens (spacing, radius, container widths), body font and line-height load after it, so override those with a more specific selector (for example html:root or body.page) rather than a bare :root/body rule. Preview uses the same order. Lets you define site-wide design tokens (CSS variables, @media queries, :hover states) without inlining on every element. Prefer named styles (create_style) for anything a style can express. Syntax errors (unbalanced braces, unclosed comments or strings, expression()/javascript:) refuse the write; selectors on platform markup ([data-block], .block-*) come back as warnings because they can change between render versions — target s-<style> classes or block custom_class instead.'),
|
|
59
|
+
default_og_image: z.string().optional().describe('Site-wide fallback og:image URL for pages without their own social image. Empty string clears.'),
|
|
60
|
+
twitter_handle: z.string().optional().describe('Twitter/X handle for twitter:site, with or without the leading @ (stored without it). Empty string clears.'),
|
|
61
|
+
organization: z.object({
|
|
62
|
+
name: z.string().optional(),
|
|
63
|
+
logo: z.string().optional().describe('Absolute logo URL for the Organization JSON-LD.'),
|
|
64
|
+
same_as: z.array(z.string()).max(50).optional().describe('Profile URLs (LinkedIn, X, Facebook, …) emitted as sameAs.'),
|
|
65
|
+
}).strict().nullable().optional().describe('Organization schema.org JSON-LD emitted on every page. Replaces the stored value as a whole; null (or all-empty) clears it.'),
|
|
66
|
+
staging_url: z.string().nullable().optional().describe('URL of a staging environment for this site, reported as urls.staging. Stored on the Site, not per version. Trailing slashes are dropped; empty string or null clears.'),
|
|
57
67
|
render_version: z.number().int().min(1).optional().describe('Platform render version. Platform output changes ship as new versions so existing sites never change look on their own. Upgrade only after previewing (get_preview_link render_version) and with the user\'s approval; the tr-upgrade-rendering skill compares every page. Lowering it returns to an earlier version.'),
|
|
58
68
|
colors: z.record(z.string()).optional(),
|
|
59
69
|
fonts: z.record(z.unknown()).optional(),
|
package/dist/tools/sites.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// Sites tools — discovery
|
|
1
|
+
// Sites tools — discovery, Site-level identity edits and the site lifecycle
|
|
2
|
+
// (archive / restore). Each maps to a v1 route with the same permission
|
|
3
|
+
// check as the corresponding portal action.
|
|
2
4
|
import { z } from 'zod';
|
|
3
5
|
import { ok, withErrorBoundary } from './helpers.js';
|
|
4
6
|
export const siteTools = [
|
|
@@ -30,7 +32,7 @@ export const siteTools = [
|
|
|
30
32
|
},
|
|
31
33
|
{
|
|
32
34
|
name: 'get_site',
|
|
33
|
-
description: 'Read this site\'s metadata (id, name, slug, domain, active version) + a urls object covering the production / fallback / preview_base URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
|
|
35
|
+
description: 'Read this site\'s metadata (id, name, slug, domain, language, ai_scripts_enabled, lifecycle {status: active|archived}, active version) + a urls object covering the production / fallback / preview_base / staging URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
|
|
34
36
|
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
35
37
|
const res = await client.get(siteId, '');
|
|
36
38
|
return ok(res);
|
|
@@ -65,16 +67,36 @@ export const siteTools = [
|
|
|
65
67
|
},
|
|
66
68
|
{
|
|
67
69
|
name: 'update_site',
|
|
68
|
-
description: 'Edit Site-level
|
|
70
|
+
description: 'Edit Site-level fields: name (display), slug (drives the {slug}.typeroll-fallback subdomain — kebab-case, 3-48 chars, unique across the org), domain (the customer\'s real hostname; pass "" to clear), language, and ai_scripts_enabled (the portal\'s "Allow AI to write block scripts" toggle; requires admin permission, as in the portal). For colors / fonts / contact info / tagline / staging_url use update_site_settings instead.',
|
|
69
71
|
inputSchema: {
|
|
70
72
|
name: z.string().min(1).optional(),
|
|
71
73
|
slug: z.string().optional().describe('Kebab-case identifier, 3-48 chars [a-z0-9-]. Empty string clears.'),
|
|
72
74
|
domain: z.string().optional().describe('Bare hostname e.g. "example.com". Empty string clears.'),
|
|
73
75
|
language: z.string().optional().describe('Default content language as a BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> and the default for alt-text generation. Empty string clears.'),
|
|
76
|
+
ai_scripts_enabled: z.boolean().optional().describe('"Allow AI to write block scripts": lets the in-portal chat assistant author block JavaScript for this site. API keys and MCP are not gated by it — they can always write block `script` and core/embed `js`. Requires admin permission.'),
|
|
74
77
|
},
|
|
75
78
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
76
79
|
const res = await client.patch(siteId, '', args);
|
|
77
80
|
return ok(res);
|
|
78
81
|
}),
|
|
79
82
|
},
|
|
83
|
+
{
|
|
84
|
+
name: 'archive_site',
|
|
85
|
+
description: 'Archive (retire) this site, the same action as Settings → Archive site in the portal. An archived site stays readable but refuses every write and publish until restored; the last published output keeps serving. Refused with 409 while a custom domain is still live or verified — move or remove the domain first. Requires an admin key of the organization that OWNS the site (a site-scoped key or the owner organization\'s org key); a key that reaches the site through a share is refused. Idempotent. Confirm with the user before calling.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
reason: z.string().max(500).optional().describe('Why the site is retired; shown in the portal.'),
|
|
88
|
+
},
|
|
89
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
90
|
+
const res = await client.post(siteId, 'lifecycle', { action: 'archive', ...(args.reason ? { reason: args.reason } : {}) });
|
|
91
|
+
return ok(res);
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'restore_site',
|
|
96
|
+
description: 'Restore an archived site so it accepts writes and publishing again, the same action as Restore in the portal. Same permission as archive_site (owner-organization admin). Idempotent: restoring an active site succeeds without change.',
|
|
97
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
98
|
+
const res = await client.post(siteId, 'lifecycle', { action: 'restore' });
|
|
99
|
+
return ok(res);
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
80
102
|
];
|
package/dist/tools/versions.js
CHANGED
|
@@ -12,7 +12,7 @@ export const versionTools = [
|
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
name: 'create_branch',
|
|
15
|
-
description: "Create a copy-on-write branch from main (or the version passed in `base`) — the RECOMMENDED first step for any larger or experimental change (redesigns, multi-page edits, trying a new design direction). Work lands on the branch, never on the live main version, until you merge_branch it. New branches default to robots_blocked:true (a half-finished design can't be indexed) and get their own stable deploy address for stakeholder review: after the branch's first trigger_deploy, read it from deploy_url in list_versions or read_version (the publishing setup decides the host: under a Hosting Group it is a v-… host under that group's site address base, otherwise the address the host reported; never construct it yourself). Pass the returned id as ?version= on every subsequent read/write. When in doubt, branch — it's cheap and keeps the live site safe. The tr-redesign-branch skill (read_skill) walks the full flow.",
|
|
15
|
+
description: "Create a copy-on-write branch from main (or the version passed in `base`) — the RECOMMENDED first step for any larger or experimental change (redesigns, multi-page edits, trying a new design direction). Work lands on the branch, never on the live main version, until you merge_branch it. New branches default to robots_blocked:true (a half-finished design can't be indexed) and get their own stable deploy address for stakeholder review: after the branch's first trigger_deploy, read it from deploy_url in list_versions or read_version (the publishing setup decides the host: under a Hosting Group it is a v-… host under that group's site address base, otherwise the address the host reported; never construct it yourself). Pass the returned id as ?version= on every subsequent read/write. When in doubt, branch — it's cheap and keeps the live site safe. The tr-redesign-branch skill (read_skill) walks the full flow. Admin permission on the site is required, as in the portal.",
|
|
16
16
|
inputSchema: {
|
|
17
17
|
name: z.string().min(1),
|
|
18
18
|
base: z.string().optional().describe('Source version id; defaults to main.'),
|
|
@@ -33,16 +33,34 @@ export const versionTools = [
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
name: 'delete_branch',
|
|
36
|
-
description: 'Discard a branch (main cannot be deleted).',
|
|
36
|
+
description: 'Discard a branch with its overrides, tombstones and revision history (main cannot be deleted). Admin permission required.',
|
|
37
37
|
inputSchema: { version_id: z.string() },
|
|
38
38
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
39
39
|
const res = await client.del(siteId, `versions/${encodeURIComponent(args.version_id)}`);
|
|
40
40
|
return ok(res);
|
|
41
41
|
}),
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'diff_version',
|
|
45
|
+
description: 'Read-only: what a branch changes relative to main, per collection (pages, partials, redirects, contentTypes, pageTemplates, blockTypes): ids it adds, modifies and deletes, plus whether it overrides site settings and totalChanges. This is exactly what merge_branch would land on main and what reset_version would discard. Use it to summarise a branch for review before asking for merge approval.',
|
|
46
|
+
inputSchema: { version_id: z.string().describe('Branch id (main has nothing to diff).') },
|
|
47
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
48
|
+
const res = await client.get(siteId, `versions/${encodeURIComponent(args.version_id)}/diff`);
|
|
49
|
+
return ok(res);
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'reset_version',
|
|
54
|
+
description: 'Discard EVERY change on a branch (all overrides and deletions), so it reads straight through to main again. The branch itself, its deploy address and its revision history are kept. Destructive for the branch\'s work: run diff_version first and get explicit user approval. Returns { cleared } — the diff that was discarded. Admin permission required. Main cannot be reset.',
|
|
55
|
+
inputSchema: { version_id: z.string() },
|
|
56
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
57
|
+
const res = await client.post(siteId, `versions/${encodeURIComponent(args.version_id)}/reset`);
|
|
58
|
+
return ok(res);
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
43
61
|
{
|
|
44
62
|
name: 'merge_branch',
|
|
45
|
-
description: 'Promote a branch\'s overrides + tombstones onto main. The branch is left in place so you can keep iterating; delete_branch removes it when you\'re done.',
|
|
63
|
+
description: 'Promote a branch\'s overrides + tombstones onto main (diff_version shows exactly what will land). The branch is left in place so you can keep iterating; delete_branch removes it when you\'re done. Admin permission required.',
|
|
46
64
|
inputSchema: { version_id: z.string() },
|
|
47
65
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
48
66
|
const res = await client.post(siteId, `versions/${encodeURIComponent(args.version_id)}/merge`);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Workflow tools — the portal's server-side workflows (WordPress migration,
|
|
2
|
+
// AI site planning, SEO / link / performance audits, content generation and
|
|
3
|
+
// improvement, schema markup, URL parity, rebuild & deploy) and creating a
|
|
4
|
+
// Site together with its first workflow.
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { ok, versionParam, withErrorBoundary } from './helpers.js';
|
|
7
|
+
const WORKFLOW_TYPES = [
|
|
8
|
+
'migration', 'site_planning', 'seo_audit', 'content_improvement', 'link_check',
|
|
9
|
+
'performance_audit', 'content_generation', 'schema_markup', 'url_parity', 'rebuild_deploy',
|
|
10
|
+
];
|
|
11
|
+
export const workflowTools = [
|
|
12
|
+
{
|
|
13
|
+
name: 'list_workflows',
|
|
14
|
+
description: 'List the workflow types this Site can run (label, description, steps, required_permission) and its most recent runs with status. Read only. Migration, site planning and URL parity pause for review; check get_workflow for paused_for_review.',
|
|
15
|
+
inputSchema: { limit: z.number().int().min(1).max(100).optional() },
|
|
16
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'workflows', args.limit ? { limit: args.limit } : undefined))),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'start_workflow',
|
|
20
|
+
description: 'Start a server-side workflow on this Site; it runs in the background and returns workflow_id. Poll get_workflow. Config per type (same fields as the portal form): migration { wp_url, helper_api_key? } (needs verified organization import storage); site_planning { business_description }; content_generation { topic, tone?, audience? }; content_improvement { limit? }; url_parity { target_origin?, source_origin?, check_source?, statuses? }; rebuild_deploy { environment: staging | production }; seo_audit, link_check, performance_audit and schema_markup take none. Needs write permission; rebuild_deploy publishes and needs admin. Workflows that change content write to the given version (default main) — get the user\'s agreement first.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: z.enum(WORKFLOW_TYPES),
|
|
23
|
+
config: z.record(z.unknown()).optional(),
|
|
24
|
+
version: versionParam,
|
|
25
|
+
},
|
|
26
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'workflows', { type: args.type, config: args.config }, args.version ? { version: args.version } : undefined))),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'get_workflow',
|
|
30
|
+
description: 'Read one workflow run: status (pending | running | paused_for_review | completed | failed), current step, progress, log, review_message/review_data when paused, results and failure_reason. Credential config is masked.',
|
|
31
|
+
inputSchema: { workflow_id: z.string() },
|
|
32
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `workflows/${encodeURIComponent(args.workflow_id)}`))),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'approve_workflow',
|
|
36
|
+
description: 'Approve the review gate of a run that is paused_for_review; it resumes from the next step in the background. Show the user review_message and review_data from get_workflow and get their approval first — approval is theirs to give, not the tool\'s. Write permission; any other status returns 409.',
|
|
37
|
+
inputSchema: { workflow_id: z.string() },
|
|
38
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, `workflows/${encodeURIComponent(args.workflow_id)}/approve`))),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'create_site_and_migrate',
|
|
42
|
+
noSite: true,
|
|
43
|
+
description: 'Create a new Site in the Organization and start a WordPress migration into it, like "Migrate from WordPress" on the portal\'s New site page. Requires an organization API key and verified organization import storage (409 import_storage_required otherwise — nothing is created). Returns site and workflow ids; poll get_workflow with that site_id. Run get_migration_readiness on the new Site if you need the preflight first.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
name: z.string().min(1).describe('Site display name; slugified into the Site id.'),
|
|
46
|
+
wp_url: z.string().url().describe('Public WordPress address, e.g. https://oldsite.example.'),
|
|
47
|
+
helper_api_key: z.string().optional().describe('Typeroll Helper plugin key from the WordPress admin, if installed. Stored for the run and never returned.'),
|
|
48
|
+
},
|
|
49
|
+
handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('sites/create-and-migrate', args))),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'create_site_and_plan',
|
|
53
|
+
noSite: true,
|
|
54
|
+
description: 'Create a new Site in the Organization and start AI site planning from a business description, like "Plan a new site with AI" on the New site page. Requires an organization API key. The plan pauses for review: read it with get_workflow and approve_workflow only after the user agrees.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
name: z.string().min(1),
|
|
57
|
+
business_description: z.string().min(1).describe('What the business does, for whom, and what sets it apart.'),
|
|
58
|
+
},
|
|
59
|
+
handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('sites/create-and-plan', args))),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
// Save button)
|
|
10
10
|
// discard_working_copy → throw the draft away
|
|
11
11
|
// list/read/restore_page_revision → saved-state history (undo)
|
|
12
|
+
// preview_page_revision → render a saved state
|
|
13
|
+
// list/read/restore_partial_revision → the same for partials
|
|
12
14
|
//
|
|
13
15
|
// The portal editor shows any agent-written draft as "Unsaved changes", so
|
|
14
16
|
// the human can also Save or Discard it from the UI.
|
|
15
17
|
import { z } from 'zod';
|
|
16
|
-
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
18
|
+
import { ok, withErrorBoundary, versionParam, writeAuthority } from './helpers.js';
|
|
17
19
|
function v(version) {
|
|
18
20
|
return version ? { version } : undefined;
|
|
19
21
|
}
|
|
@@ -42,10 +44,11 @@ export const workingCopyTools = [
|
|
|
42
44
|
description: 'SAVE a doc\'s unsaved draft: promote it onto the saved doc through the canonical write path (revision snapshot, SEO transform, redirect hygiene) and delete the draft. Identical to the Save button in the portal editor. Returns { committed, seo_warnings, auto_redirects, retired_redirects }. Never changes publish status. Deploys only ship saved content, so commit before trigger_deploy. Equivalent shortcut: save:true on the write tools.',
|
|
43
45
|
inputSchema: {
|
|
44
46
|
target: wcTargetSchema,
|
|
47
|
+
...writeAuthority,
|
|
45
48
|
version: versionParam,
|
|
46
49
|
},
|
|
47
50
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
48
|
-
const res = await client.post(siteId, wcPath(args.target), {}, v(args.version));
|
|
51
|
+
const res = await client.post(siteId, wcPath(args.target), { ...(args.override_reason ? { override_reason: args.override_reason } : {}) }, v(args.version));
|
|
49
52
|
return ok(res);
|
|
50
53
|
}),
|
|
51
54
|
},
|
|
@@ -109,10 +112,71 @@ export const workingCopyTools = [
|
|
|
109
112
|
page_id: z.string(),
|
|
110
113
|
revision_id: z.string(),
|
|
111
114
|
save: z.boolean().optional().describe('Save immediately (default false: leave it as an unsaved draft to preview).'),
|
|
115
|
+
...writeAuthority,
|
|
112
116
|
version: versionParam,
|
|
113
117
|
},
|
|
114
118
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
115
|
-
const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/revisions/${encodeURIComponent(args.revision_id)}/restore`, { save: args.save === true }, v(args.version));
|
|
119
|
+
const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/revisions/${encodeURIComponent(args.revision_id)}/restore`, { save: args.save === true, ...(args.override_reason ? { override_reason: args.override_reason } : {}) }, v(args.version));
|
|
120
|
+
return ok(res);
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'preview_page_revision',
|
|
125
|
+
description: 'Render one saved page revision as the whole preview document (header, body, footer, site CSS and block scripts), exactly what the editor\'s History panel shows before a restore. Read-only. The revision\'s fields are laid over the current page; the current header, footer and settings give context. Returns { page_id, revision_id, slug, title, rendered_html, internal_links }. Pass annotate:true to tag block roots with data-block-id + data-block-type. Compare with get_page_preview before restore_page_revision.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
page_id: z.string(),
|
|
128
|
+
revision_id: z.string(),
|
|
129
|
+
annotate: z.boolean().optional(),
|
|
130
|
+
version: versionParam,
|
|
131
|
+
},
|
|
132
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
133
|
+
const query = { ...(v(args.version) ?? {}) };
|
|
134
|
+
if (args.annotate)
|
|
135
|
+
query.annotate = 'true';
|
|
136
|
+
const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/revisions/${encodeURIComponent(args.revision_id)}/preview`, query);
|
|
137
|
+
return ok(res);
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'list_partial_revisions',
|
|
142
|
+
description: "Saved-state history of a partial (header, footer or global block) on this version, newest first. Every save snapshots the partial as it was BEFORE the save. Returns { revisions: [{ id, created_at, created_by, note, name, content_mode, date_updated }], total }. Use read_partial_revision for the full document and restore_partial_revision to undo.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
partial_id: z.string(),
|
|
145
|
+
limit: z.number().int().min(1).max(100).optional().describe('Newest N entries (default 50).'),
|
|
146
|
+
version: versionParam,
|
|
147
|
+
},
|
|
148
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
149
|
+
const query = { ...(v(args.version) ?? {}) };
|
|
150
|
+
if (args.limit)
|
|
151
|
+
query.limit = String(args.limit);
|
|
152
|
+
const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}/revisions`, query);
|
|
153
|
+
return ok(res);
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'read_partial_revision',
|
|
158
|
+
description: 'Read one partial revision with the full saved document (name, kind, html_content or blocks, status) — compare it with read_partial before restoring.',
|
|
159
|
+
inputSchema: {
|
|
160
|
+
partial_id: z.string(),
|
|
161
|
+
revision_id: z.string(),
|
|
162
|
+
version: versionParam,
|
|
163
|
+
},
|
|
164
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
165
|
+
const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}/revisions/${encodeURIComponent(args.revision_id)}`, v(args.version));
|
|
166
|
+
return ok(res);
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'restore_partial_revision',
|
|
171
|
+
description: "Restore a partial revision's name and content (HTML or block tree) into the partial's DRAFT, replacing the current draft. Pass save:true to save it at once; the save snapshots the current partial first, so the restore is itself undoable. Status and kind are kept. A revision from the other content mode is refused (409) — switch mode first. Returns { restored_revision, saved, has_unsaved_changes, staged_fields, sanitization_warnings }.",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
partial_id: z.string(),
|
|
174
|
+
revision_id: z.string(),
|
|
175
|
+
save: z.boolean().optional().describe('Save immediately (default false: leave it as an unsaved draft to preview).'),
|
|
176
|
+
version: versionParam,
|
|
177
|
+
},
|
|
178
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
179
|
+
const res = await client.post(siteId, `partials/${encodeURIComponent(args.partial_id)}/revisions/${encodeURIComponent(args.revision_id)}/restore`, { save: args.save === true }, v(args.version));
|
|
116
180
|
return ok(res);
|
|
117
181
|
}),
|
|
118
182
|
},
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -45,9 +45,9 @@ setting one-off values; ask first when it changes the site's look.
|
|
|
45
45
|
A small label above a heading is the heading block's `eyebrow`, and a line
|
|
46
46
|
below it is its `subtitle` (render version 2), each with its own style field.
|
|
47
47
|
Never write them as separate text blocks with classed HTML. In text blocks,
|
|
48
|
-
style whole paragraphs with site text styles (`<p class="s-lead">`)
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
style whole paragraphs with site text styles (`<p class="s-lead">`). Content
|
|
49
|
+
that needs headings with parts, images or buttons belongs in their own blocks,
|
|
50
|
+
not in a text block's HTML.
|
|
51
51
|
|
|
52
52
|
`list_block_templates` and `list_partials` too. Start a common section from a
|
|
53
53
|
block template (`insert_block_template`, then edit the copy). Use a global
|
package/skills/tr-forms.md
CHANGED
|
@@ -6,8 +6,8 @@ description: Use when the user wants to add a contact form, newsletter signup, b
|
|
|
6
6
|
# Add a form to a Typeroll site
|
|
7
7
|
|
|
8
8
|
Typeroll forms are server-backed. The platform renders the form shell, accepts
|
|
9
|
-
signed submissions, validates declared fields, stores submissions, and
|
|
10
|
-
|
|
9
|
+
signed submissions, validates declared fields, stores submissions, and runs
|
|
10
|
+
email and webhook actions configured in the portal or through MCP/API.
|
|
11
11
|
|
|
12
12
|
## Choose the placement
|
|
13
13
|
|
|
@@ -97,9 +97,34 @@ actions in the form editor:
|
|
|
97
97
|
`X-Typeroll-Signature`, sends an idempotency key, retries transient failures,
|
|
98
98
|
and stores delivery status.
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
Agents manage the same actions with `read_form` (actions with secrets masked)
|
|
101
|
+
and `update_form patch={ actions: [...] }`, with admin permission as in the
|
|
102
|
+
portal. `actions` replaces the whole list: read the form, change what you need
|
|
103
|
+
and send the list back. Examples:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
update_form form_id=contact patch={ actions: [
|
|
107
|
+
{ type: "email", config: { to: "hello@example.com", subject: "New lead: {{name}}",
|
|
108
|
+
body: "<p>{{message}}</p>", include_all: true, reply_to: "{{email}}" } },
|
|
109
|
+
{ type: "email", config: { to: "{{email}}", subject: "Thanks, {{name}}",
|
|
110
|
+
body: "<p>We will get back to you within a day.</p>" } }
|
|
111
|
+
] }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A webhook needs `url` (https), an explicit `fields` allowlist and a signing
|
|
115
|
+
`secret`; send the masked value back to keep a stored secret.
|
|
116
|
+
`get_form_capabilities` lists every action type the site offers, including
|
|
117
|
+
types from installed apps, with the config fields each one takes.
|
|
118
|
+
|
|
119
|
+
Email actions send through the site's email provider. Check it with
|
|
120
|
+
`get_email_settings`; if `email` is null, connect one with `set_email_settings`
|
|
121
|
+
(for example `type: "postmark"`, `from`, `config: { server_token }`) and confirm
|
|
122
|
+
with `send_test_email`. Without a provider, submissions are stored but nobody is
|
|
123
|
+
notified. These tools need admin permission, like Settings → Email & notifications.
|
|
124
|
+
|
|
125
|
+
`list_form_submissions` reads what visitors sent, `read_form_submission` reads
|
|
126
|
+
one entry (admins also see webhook delivery status) and `delete_form_submission`
|
|
127
|
+
removes one.
|
|
103
128
|
|
|
104
129
|
## Verify
|
|
105
130
|
|
|
@@ -107,8 +132,11 @@ to configure them.
|
|
|
107
132
|
2. Preview the page and confirm the authoring reference has expanded to a form
|
|
108
133
|
with `data-tr-form-el`, a signed token, and the platform runtime.
|
|
109
134
|
3. Submit a test entry and confirm it appears in Forms → Submissions.
|
|
110
|
-
4. If a webhook is configured, confirm its delivery status
|
|
111
|
-
system's idempotency key before
|
|
135
|
+
4. If a webhook is configured, confirm its delivery status
|
|
136
|
+
(`read_form_submission`) and the receiving system's idempotency key before
|
|
137
|
+
deploying.
|
|
138
|
+
5. If an email action is configured, confirm `get_email_settings` shows a
|
|
139
|
+
provider and the test message arrived.
|
|
112
140
|
|
|
113
141
|
## Common patterns
|
|
114
142
|
|
|
@@ -143,7 +171,7 @@ Booking request:
|
|
|
143
171
|
|
|
144
172
|
- Do not hand-write a form, token, honeypot, or submit script.
|
|
145
173
|
- Do not put a raw `<script>` in page HTML; the sanitizer removes it.
|
|
146
|
-
- Do not
|
|
174
|
+
- Do not drop existing actions by accident: `actions` replaces the list.
|
|
147
175
|
- Do not send every submitted field to a webhook by default; choose the
|
|
148
176
|
smallest allowlist the external register needs.
|
|
149
177
|
- `submit_token` is stable until the platform rotates its form-signing secret;
|
package/skills/tr-import-url.md
CHANGED
|
@@ -61,8 +61,8 @@ small label directly above a heading is an eyebrow style, not a heading of its
|
|
|
61
61
|
own: put it in the heading block's `eyebrow` field (and a line under the
|
|
62
62
|
heading in `subtitle`). Never carry classed raw HTML into text blocks to keep a
|
|
63
63
|
look: the editor cannot edit it, and the next render version may not keep it.
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Build such sections with heading, text, image and button blocks; Typeroll does
|
|
65
|
+
not convert HTML into blocks.
|
|
66
66
|
|
|
67
67
|
### 2. Measure the source and protect existing target work
|
|
68
68
|
|