← Unbounce - Classic BuilderCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to Unbounce - Classic Builder
Snapshot Sep 30, 2026 · 23:08 UTC · version 1.0.0
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"name": "mcp-page-authoring",
"description": "Best practices for authoring an Unbounce landing page through Unbounce MCP — writing the HTML/CSS/JS for create_page_from_html, create_variant_from_html, and update_variant_from_html. Use when the user asks to create, build, design, generate, or edit an Unbounce landing page or variant, or to personalize page text by URL parameter (Dynamic Text Replacement). Applies only to pages authored through Unbounce MCP, not pages hand-built in the Unbounce builder.",
"included_files": [
{
"relative_path": "rules/google-analytics.md",
"size_in_bytes": 3770
},
{
"relative_path": "scripts/ga-click-tracking.js",
"size_in_bytes": 3006
}
],
"skill_md_contents": "---\nname: mcp-page-authoring\ndescription: Best practices for authoring an Unbounce landing page through Unbounce MCP — writing the HTML/CSS/JS for create_page_from_html, create_variant_from_html, and update_variant_from_html. Use when the user asks to create, build, design, generate, or edit an Unbounce landing page or variant, or to personalize page text by URL parameter (Dynamic Text Replacement). Applies only to pages authored through Unbounce MCP, not pages hand-built in the Unbounce builder.\nrequires:\n mcpServers:\n - unbounce-mcp\n---\n\n# Authoring an Unbounce MCP page\n\nThese rules apply when you write the HTML/CSS/JS body for `create_page_from_html`,\n`create_variant_from_html`, or `update_variant_from_html`. The page is a full-bleed\ncustom-HTML page — you own the markup end to end. Design the page to be optimized\nfor conversions: by default, every layout, copy, and hierarchy decision should\nserve the page's single conversion goal.\n\nThis covers **MCP-authored pages only**, and the split is exclusive both ways:\n\n- A page hand-built in the Unbounce builder is not editable this way —\n `update_variant_from_html` refuses it. Replicating or modernizing a\n builder-built page is a different, audit-first job.\n- An MCP-authored page **cannot be edited in the Unbounce builder**. Never tell\n the user they can open, tweak, swap images, or finish the page \"in the\n Unbounce builder/editor\" — they cannot. Every edit goes through these MCP\n tools: the user asks for the change, or supplies updated HTML.\n\n## Updating a variant: HTML/CSS replace, JS is left alone\n\n`update_variant_from_html` full-replaces the body HTML and the stylesheet, so\nsend every stylesheet the variant should keep on every call. **JavaScript is the\nexception: omit `scripts` and the variant's existing custom-JavaScript elements\nare kept**, reported back as `scripts_preserved`.\n\nThat matters because a variant can carry scripts nobody authored through these\ntools — an **Unbounce popup** attaches itself as one. So:\n\n- Editing HTML or CSS? Just omit `scripts`. The popup and anything like it\n survive, and you don't need to know they were there.\n- Changing the scripts? Pass `scripts` with the **complete** desired set — it\n replaces all of them, including external includes. Whatever it drops comes\n back in `removed_scripts`. Those entries — and the `scripts_preserved` ones —\n are already in `scripts` entry form: pass one straight back to restore it,\n unchanged, no translation. A plain include appears as `src` (with `async` /\n `defer` if it had them); everything else — including an include whose tag\n carries other attributes, such as a `data-*`-configured analytics embed — comes\n back as verbatim `tag`. Read the variant first\n (`get_variant`) if you don't know what's there.\n- Removing every script is deliberate: pass `scripts: []`.\n\n## Writing a `scripts` entry\n\nEach entry is one custom-JavaScript element. Use exactly one of:\n\n- **`ref`** — an `upload://` reference to a .js file. Its text is the script\n body; don't wrap it in `<script>` tags. A bare string entry is shorthand for\n this.\n- **`src`** — an absolute http(s) URL to include, for a third-party embed: an\n Unbounce popup (`https://<id>.js.ubembed.com`), a tag manager, a chat widget, a\n pixel. Add `async` / `defer` here if the vendor's snippet has them. This is the\n right form when the user hands you a `<script src=…>` tag.\n- **`tag`** — verbatim `<script>` markup, for what the other two can't say: a\n `type=\"module\"` script, a tag with both a `src` and a body, or several tags\n that belong together.\n\nAdd `name` (what the user calls it — it labels the element in Unbounce's Custom\nJavaScripts panel) and `placement` (`head`, `body_top`, or `body_bottom`,\ndefault). Consent managers, tag managers, and anti-flicker snippets belong in\n`head`; most other things are fine at the default.\n\n`get_variant` returns every script in the matching form, so you can read, edit,\nand re-submit any of them unchanged.\n\n**A `<script>` in the body HTML does run on the published page** — it is stored\nverbatim in the Custom HTML block and served as real markup. But it lands\nbody-placed, unnamed, and invisible in the builder's Custom JavaScripts panel, so\nprefer a `scripts` entry whenever the user names the script or asks for a\nparticular placement.\n\n## Conversion-focused structure\n\nA landing page exists to convert — design toward that goal affirmatively, not\njust by obeying constraints. Treat the bullets below as the default posture: if\nthe user explicitly asks for something that works against conversion (a nav menu,\nmultiple offers, a long form), note the trade-off briefly once, then build what\nthey asked for.\n\n- **Benefit-led headline.** The headline states the visitor's payoff and matches\n the offer and traffic intent — not the company name or a clever tagline.\n- **CTA prominence and hierarchy.** One primary call to action, visible without\n scrolling; size, contrast, and whitespace should point the eye at it. On longer\n pages, repeat the CTA as anchor links.\n- **Minimal form friction.** Ask only for the fields the offer genuinely needs —\n every extra field costs conversions. Label the submit button with the payoff\n (\"Get the guide\"), not \"Submit\".\n- **Real social proof near the ask.** Place testimonials, numbers, or logos the\n user supplied next to the form or CTA. **Never fabricate trust signals** — no\n invented testimonials, star ratings, customer logos, statistics, or scarcity\n claims (\"Only 3 spots left!\"). If the user hasn't supplied any, ask for it while\n you're still gathering requirements; once you're already generating the page,\n leave social proof out and mention the omission so the user can supply proof\n for a follow-up edit.\n- **One form.** A lead-gen page has exactly one `<form>`. Never render two. CTA\n buttons in sections where the form isn't visible are anchor links\n (`<a href=\"#form\">`) that scroll to the single form — not extra forms. Pages\n with no form (click-through pages) are exempt.\n- **No navigation.** No nav bars, menus, or footer link lists. Every outbound\n link is an exit that costs conversion. The only acceptable links are the primary\n CTA anchors and legally required links (privacy/terms), placed quietly in the\n footer.\n- **Responsive, clean HTML.** Use normal document flow (flexbox/grid), relative\n units, and `max-width` — not absolute positioning. The page must reflow on\n mobile.\n\n## Assets\n\n- **Prefer `upload` over fat data URIs.** Base64 data URIs work but bloat the\n payload (~33% larger) and count against the 1 MB tool-input cap. Upload real\n images/fonts first and reference the returned CDN URL. Relative refs in your\n bundle and data URIs are rehosted for you; identical bytes are de-duplicated\n automatically against the client's asset library.\n\n- **One upload call per bundle.** Every `upload` / `upload_inband` call creates a\n fresh upload folder, and a relative reference inside your HTML/CSS (e.g.\n `src=\"page.js\"`, `url(images/hero.png)`) resolves only within the HTML's own\n folder — so upload the HTML together with everything it references by relative\n path in one call. Refs from different calls still work anywhere a tool takes an\n explicit `upload://` reference (`html_ref` / `css_refs` / a `scripts` entry's\n `ref`, or written\n in full inside the markup); refs are immutable snapshots, so re-uploading a file\n never changes what an earlier ref points to.\n\n- **An image's bytes must match its extension.** The file extension declares the\n type an asset is stored and served as, so the bytes are checked against it: a PNG\n named `.svg`, or anything that isn't a real image, is refused rather than stored\n under the wrong type. Name files for what they actually are.\n\n- **SVGs must be static artwork.** An SVG is served as a live document, so one\n carrying `<script>`, an `on…=` event handler, a `<foreignObject>`, a\n `javascript:` URL, or an external `href` is refused. Ordinary exported artwork —\n including animated SVG — is fine. If an SVG is rejected, flatten it on export or\n use a PNG.\n\n## Authoring from a client that can't run shell commands\n\n`upload` and `download` hand you `curl` commands to run locally. If your client\nhas no shell or filesystem (a web/desktop chat that only calls tools), you can't\nrun them — use the **in-band** pair instead:\n\n- **`upload_inband`** — pass each file's **text content inline** (an array of\n `{ path, content }`); it returns the same `upload://` references you'd get from\n `upload`. Wire those to `create_page_from_html` / `create_variant_from_html` /\n `update_variant_from_html`'s `html_ref` / `css_refs` / `scripts` exactly as\n usual — those tools don't change.\n- **`download_inband`** — pass the `upload://` references from `get_variant` (or\n `upload_inband`) and it returns the content **inline in the result**, so you can\n read a variant back and iterate.\n\nConstraints, because the bytes travel through the conversation:\n\n- **Text only.** HTML/CSS/JS. **Images and fonts must be external `http(s)`\n URLs** — you can't upload binary bytes this way. Host the image elsewhere and\n reference its URL, or reuse an existing asset's `cdn_url` from `list_assets`;\n `create_page_from_html` leaves external URLs untouched. `download_inband`\n refuses a binary or over-2 MiB object and tells you to use `download`.\n- **Keep files small.** The request rides the normal transport, so a very large\n paste will fail — author lean pages.\n- Because your images are always external URLs, keep `check_external_refs` at its\n default (`fatal`) so a dead image URL is caught before the page is created,\n not after publish.\n\n## Scripts, forms, and the auto-injected bits — don't fight them\n\n- **Don't hand-wrap JS in `<script>`.** Provide raw JavaScript, not a `<script>`\n block — it's wrapped for you, so wrapping it yourself double-wraps it. (This is\n the opposite of what you'd do writing a static HTML file.)\n- **Form submission works out of the box; the confirmation is a customizable\n fallback.** Submission is wired up automatically from the form fields you\n author — no plumbing needed. If you don't handle the post-submit experience\n yourself, a default confirmation dialog is shown. To customize it, register a\n post-submit handler — see the next bullet for the one way that works.\n- **Register post-submit handlers with `.push(fn)`, never `= fn`.** The published\n runtime stores `window.ub.hooks.afterFormSubmit` as an **array** and invokes it\n with `Promise.all(hooks.map(h => h(...)))`. Assigning a function replaces the\n array, so the first submit throws `TypeError: hooks.map is not a function`, the\n post-submit promise rejects, and **neither your custom confirmation nor the\n default dialog ever shows**. Always push:\n\n ```js\n window.ub = window.ub || {};\n window.ub.hooks = window.ub.hooks || {};\n window.ub.hooks.afterFormSubmit = window.ub.hooks.afterFormSubmit || []; // defensive\n window.ub.hooks.afterFormSubmit.push(function () {\n // reveal your confirmation panel here\n });\n ```\n\n This is independent of whether the form is multi-step. When reviewing a page,\n grep the controller for `afterFormSubmit =` and rewrite any assignment to a push.\n\n- **A signature comment is auto-injected** at the top of every variant body\n (Unbounce MCP version · client · timestamp). It's re-stamped on each write — don't\n duplicate it, don't strip it, and don't treat it as page content when you read a\n variant back.\n\n## Conversion goals — ask the user what should count\n\nA page converts when a visitor does the thing it exists for. Unbounce counts\nthat through **conversion goals**: the form submission (on by default) and any\nlinks you nominate. After creating or updating a page, the tool result's\n`conversion_goals` block lists every candidate — the form plus each distinct\nlink/phone URL with its anchor texts (`get_variant` shows the same for an\nexisting page). Use it to have the goal conversation:\n\n- **Ask the user which candidates should count**, then call\n `set_conversion_goals` with the **complete** desired set (it replaces, not\n adds). Recommend the goal that matches the page's purpose: a click-through\n page's goal is its CTA link, not an incidental newsletter form.\n- **Goals are URL-identified.** Every anchor pointing at a chosen URL converts —\n the candidate's `anchor_texts` length shows how many places that is. Two\n near-identical URLs (trailing slash, query param) are separate candidates;\n point that out if they look like the same destination.\n- **A trackable CTA must be a real `<a href>`** (http(s) or `tel:`). JS-driven\n buttons can't be tracked — goal tracking rewrites the href. `#anchors` and\n `mailto:` can't be goals (platform rule).\n- **Turning the form goal off** (`form_submission: false`) still captures every\n lead; it just stops counting submissions as conversions.\n- **Link/phone goals don't show in the Unbounce builder's Conversion Goals\n panel** — but they _are_ tracked. That panel lists only builder-native elements\n (form, button, image, linked text box), and an MCP-authored page is a single\n custom-HTML element, so its anchors never appear there even though a click on\n the published page counts as a conversion. If a user asks why a link goal is\n \"missing\" in the builder, that's expected: manage these goals here with\n `set_conversion_goals`, not in the builder (editing goals in the builder can\n drop them). The form goal is unaffected — a form is builder-native.\n- **Changing goals on a published page** redefines its conversion rate\n mid-history. Relay the tool's stats note: `reset_page_stats` gives a clean\n baseline if the user wants one (destructive — their call).\n- Goals persist across edits automatically — updates re-apply them and report\n any new candidate URLs or orphaned goals; you only need `set_conversion_goals`\n when the _set_ should change.\n\n## Google Analytics click tracking — add it on every page\n\nWhen a page's domain has the Google Analytics integration on, the published page\nalready tracks pageviews and form submissions — but **not clicks on links**,\nbecause Unbounce's built-in GA link tracker only attaches to elements the Unbounce\nbuilder produces, which an MCP page (hand-written `<a>` tags) doesn't have. Close\nthat gap on every page you author:\n\n- **Include [`scripts/ga-click-tracking.js`](scripts/ga-click-tracking.js) as a\n `scripts` entry** (name it e.g. \"GA click tracking\", default `body_bottom`).\n Read the file and pass its contents verbatim — don't hand-roll your own. It\n wires every `<a>` and emits builder-matching GA events.\n- **It's safe by default.** The script feature-detects `gtag`/`ga` and no-ops when\n the domain has no GA integration, so add it unconditionally — that's why it's\n default-on, not something to ask about.\n- **Never add the GA loader** (`gtag.js` / `analytics.js` / a `gtag('config', …)`\n call). GA is injected by Script Manager; a second loader double-counts\n pageviews. This script only sends events.\n\nThe why, the guardrails, and the maintenance note live in\n[rules/google-analytics.md](rules/google-analytics.md) — read it if you need to\nexplain the behaviour or hit an edge case.\n\n## Third-party form endpoints (Insightly, Marketo, HubSpot, Pardot, …)\n\nSometimes the form must POST leads to an external system — the user is cloning a\npage with an existing vendor-hosted form handler, or their CRM owns the lead\nflow. That is the one case where you deliberately bypass the built-in form\nhandling described above.\n\n- **A literal `<form>` gets taken over.** Submission of any `<form>` you author\n is wired to Unbounce lead capture automatically — the `action` you wrote is\n not what handles the submit. To preserve an external endpoint, don't put the\n `<form>` in the HTML: leave a mount point (`<div id=\"form-mount\"></div>`) and\n **inject the form markup at runtime from your JavaScript** (build the markup\n as a string, set the mount's `innerHTML`, then load the vendor's scripts in\n the order the reference page loads them). The runtime leaves JS-injected\n forms alone. The injected string must be the vendor's documented embed\n snippet (or the reference page's markup) reproduced verbatim — never\n interpolate unsanitised user-supplied or URL-derived values into it.\n- **Say what the user gives up.** A JS-injected form bypasses Unbounce lead\n capture entirely: no leads in Unbounce, no\n conversion tracking, and `get_variant` reports `has_form: false`. Submissions\n exist only in the external system. (`has_form` is derived from the authored\n HTML source, so it is deterministically false for an injected form — that's\n expected, not a defect.) State this trade-off when you propose the approach —\n the user may prefer the built-in form plus a separate CRM integration\n instead.\n- **`afterFormSubmit` hooks never fire for an external form.** The\n `window.ub.hooks` machinery only runs for Unbounce-captured forms. Build the\n post-submit experience (thank-you message or redirect) into your own code, the\n way the vendor's embed does it.\n- **Expect domain allowlists — the top cause of \"the button does nothing\".**\n reCAPTCHA site keys and vendor form handlers are typically locked to approved\n domains. On a preview or test-domain URL the key fails domain validation, no\n token is issued, the submit callback never runs, and clicking the button\n silently no-ops — the form is not broken, the domain isn't approved. Warn the\n user up front that the form can't fully work until the page's final domain is\n added to the reCAPTCHA key and the vendor's allowed domains.\n- **Verifying before go-live.** Have the user open the browser console and click\n submit — a reCAPTCHA \"Invalid domain for site key\" error confirms the\n allowlist diagnosis. To exercise the rest of the flow on a test domain, they\n can temporarily swap in Google's public reCAPTCHA **test** site key, then\n restore the real key before publishing to the approved domain. Never leave\n the test key on a live page — it returns a passing token for **every**\n request, including automated bots, so spam and abuse submissions flow\n straight through to the vendor endpoint unchallenged.\n- **The one-form rule still applies.** The injected form is the page's single\n form; other CTAs anchor-link to its section.\n\n## Dynamic Text Replacement (DTR)\n\nDTR personalizes page text from a URL query parameter, so the same page shows\ndifferent words depending on how it was reached — e.g. visiting with\n`?city=Portland` shows \"Portland\" where the page would otherwise read \"Vancouver\".\nCommon for paid-search keyword insertion and location/audience personalization.\n\nThere is no DTR tool: you add it by writing a `ub:dynamic` tag directly in the\nHTML you author; it takes effect when the page is served.\n\n```html\n<ub:dynamic method=\"titlecase\" parameter=\"city\">Vancouver</ub:dynamic>\n```\n\n- **`parameter`** — the URL query-string key that supplies the replacement value\n (`parameter=\"city\"` → `?city=Portland`). Pick a short, lowercase name that\n matches the meaning of the text.\n- **Default text** — the tag's inner text (`Vancouver` above) is shown when the\n page is visited without that parameter. Always make it a sensible standalone\n value; most visitors arrive with no parameter and see exactly this.\n- **`method`** — casing applied to the incoming value: `titlecase` (the right\n default for display text), `uppercase`, `lowercase`, or `capitalized`. Omit\n `method` to insert the value exactly as passed.\n- **`wrap`** (optional) — set `wrap=\"true\"` to wrap the substituted value in a\n `<span class=\"ub-dynamic\">…</span>` so you can style or target just the dynamic\n text with CSS. Omitted, the value is inserted inline. Rarely needed.\n- **Testing** — resolution happens at serve time on the **published** page (editor\n previews show the default, not a substituted value). To verify, publish, then\n load the page with `?<parameter>=SomeValue` appended — and give the user that\n test URL so they can check it.\n\n**Ask before adding DTR.** Do not add it on your own initiative. When creating or\nediting a page, ask the user whether they want any text personalized by a URL\nparameter; if so, confirm which text and which parameter name(s), and wrap only\nthe text they approve. If they decline, author plain text.\n\n**DTR is a paid feature — it may be plan-gated.** If the client's account isn't\nentitled to Dynamic Text Replacement, the content-write tools reject HTML\ncontaining a `<ub:dynamic>` tag with a `…dynamic-text-not-entitled` error (the\n`bulk_edit_variants` batch reports it as a per-variant `error`). When that\nhappens, either remove the `<ub:dynamic>` tag(s) and author plain text, or tell\nthe user their plan needs Dynamic Text Replacement to use it — check the plan\nwith `get_account_plan`. Already-published DTR pages keep working regardless.\n\n**DTR also works in the page title and meta fields.** `set_page_metadata`'s\n`title`, `description`, and `keywords` accept `<ub:dynamic>` tags — e.g. a\n`<title>` that echoes the ad keyword for paid-search campaigns. The same\nask-first rule and plan gate apply. The Open Graph fields do **not** take DTR\n(social scrapers fetch the page without campaign parameters anyway).\n\n## Page metadata (SEO & social sharing)\n\nThe page `<title>`, meta description, robots noindex, favicon, and Open Graph\n(`og:*`) tags are **page settings, not page HTML** — your authored HTML becomes\nthe page **body**, where search engines and social scrapers never look for\nthem. Set them with `set_page_metadata`; read them back via `get_variant`'s\n`metadata` block. As a safety net, the HTML-authoring tools **auto-extract**\nthese head tags from submitted markup and apply them as metadata (reported\nunder `head_metadata` in the result) — but on `update_variant_from_html` /\n`create_variant_from_html` that lands on **one variant only**, so prefer\n`set_page_metadata` for deliberate metadata work and to converge variants.\n\n- **Always set a title and description** on a page the user intends to publish\n — a page without them shows the platform default (or nothing) in search\n results and social shares. Derive them from the page's offer; keep the title\n ≲60 characters and the description ≲160 (search results truncate past that).\n- **Open Graph drives the social share card** (Facebook, LinkedIn, Slack,\n iMessage, …). `type: \"website\"` is right for landing pages; give it a\n `title`, `description`, and an `image` (1200×630, under 5 MB, https) — a\n card without an image renders as bare text. X/Twitter reads these `og:*`\n tags too, but shows the small card without a `twitter:card` meta tag — which\n the platform cannot store, so don't promise a large Twitter card.\n- **Skip `keywords`.** Google has ignored meta keywords since 2009 and heavy\n keyword lists can read as a spam signal. Leave it unset unless the user\n insists.\n- **`hide_from_search_engines: true`** emits a robots noindex — right for\n campaign pages the user doesn't want in organic search results; ask rather\n than assume.\n- **Metadata is applied to every active variant by default** (variants share\n one URL, so divergent titles/OG make the search snippet and share card\n depend on the traffic split). Target one variant with `letter` only when the\n user deliberately wants that.\n- **Staged like content**: on a published page the live head keeps its old\n values until the next `publish_page` — say so when you set metadata on a\n live page.\n- **Head tags with no platform slot** — `twitter:*`, `<link rel=\"canonical\">`,\n hreflang alternates, `og:*` beyond type/title/description/image/url — cannot\n reach the published `<head>`; the authoring tools warn when submitted HTML\n contains them. Don't promise them to the user.\n\n## Reviewing your work\n\n- Read a variant's current content back with `get_variant`.\n- **Previewing locally:** the source refs `get_variant` returns (`html_ref` /\n `css_refs` / a `scripts` entry's `ref`) are streams of one page and do **not** render\n individually — `body.html` opened alone shows an unstyled fragment. For a\n local preview, fetch the also-returned **`preview_ref`** (`preview.html`, via\n `download` or `download_inband`) and open that file: it is a standalone\n composed document (images load from their CDN URLs; form submission is\n disabled in it). It is **view-only** — never edit it or submit it back as\n `html_ref`; make edits in the source files and resubmit those.\n- Content is **staged** by the create/update tools; it goes live on `publish_page`.\n- `list_page_variants` returns a `preview_url` per variant — a logged-in link for\n a human to **review** a staged variant before publish. It is a viewing\n affordance only, not an editing path — don't present it as a way to edit.\n"
}SHA-256: 97881e381bf98b3325f0668b274b3f24951c3719ee66a84f73ba733a256dc9e6