Author a custom page
What this is
upload-custom-page is the MCP tool that creates and updates custom pages — pages whose entire body you author yourself, as an Astro/JSX fragment or a complete HTML document, with total layout freedom: your own structure, your own CSS, animation, interactivity. The platform contributes the surrounding shell (document head, metadata, theme) and suppresses documentation chrome — no table of contents, no prev/next links, excluded from site search. The body region is yours.
Custom pages are made for landing pages, product tours, pricing, about and contact pages: anywhere design matters more than document structure. For markdown content, use a knowledge page instead (create-page / update-page — see Page types).
How it works
One call both creates and updates. upload-custom-page derives a deterministic document id from slug and upserts: if no page exists for the slug, it creates a new draft; if a custom page already exists there, it updates it — and when that page is live, the edit lands as a draft while the live URL keeps serving the current version. Repeat edits collapse into the same draft. Nothing you upload is public until publish → publish-confirm completes (see Publishing). The conversational convention is to present the draft — its content or draft_url — for explicit approval before publish-confirm: publishing without review is technically possible, but it is against the platform’s documented workflow.
The upload pipeline runs in a fixed order: project-name safeguard → language check → is_faq guard → 2 MB size cap → blocked-href check (javascript:, data: and vbscript: hrefs are rejected) → lint (warnings only, never blocks) → translation-marker extraction → upsert. Any failure rejects the whole call with a precise message — Custom page rejections and fixes catalogs every one.
Translation is automatic. Wrap every visitor-facing string in a marker — <T key="…" default="…"> in Astro/JSX, data-t-key in HTML — and publish machine-translates the marker values, plus title and description, into every other locale configured for the custom-pages module. Never hand-translate, never upload per-language copies: one source page fans out to all locales, each at its own URL — /{canonical_slug}/ for the default locale, /{locale}/{canonical_slug}/ for the rest — with hreflang alternates emitted automatically once two or more locale variants are live. Unmarked text is never translated; it ships verbatim to every locale.
Quality is part of the contract: build to a Lighthouse score of 95 or higher in all four categories (Performance, Accessibility, Best Practices, SEO). When the platform’s pre-publish gate runs, custom pages are audited against that bar and a failing score fails the publish; a force override exists, and its use is recorded in the audit trail. The lint warnings in the upload response point at the most common Performance failures — fix them before they cost you a publish.
When to use it
- You are building a landing, marketing, pricing, about, or contact page and want full control of markup, style, and behavior.
- You have a vibe-coded export — Lovable, v0, Bolt, or hand-written HTML/Astro — to put live on a ComStack site.
- The page needs animation, custom interactivity, or a layout markdown cannot express.
- You are changing copy, design, or media on an existing custom page: same
slug, newbody_source. - Not for documentation, guides, or FAQ content — knowledge pages get full body translation, FAQ JSON-LD, and site search; custom pages deliberately do not.
Parameters
| Parameter | Type | Required | Constraints / default |
|---|---|---|---|
project_id | string | yes | The project id, from list-my-projects or get-project-state. |
project_name | string | yes | Safeguard echo-back: must match the project’s exact name (compared case-insensitively, after trimming). A mismatch rejects the call. |
slug | string | yes | 1–256 chars. URL slug in lowercase kebab-case; / nests segments; the literal index is the locale root. Full rules below. |
body_source | string | yes | The complete page source — an Astro/JSX fragment or an HTML document. Hard cap: 2 MB. |
title | string | yes | 10–60 characters. Auto-translated per locale. |
description | string | yes | 50–160 characters. Auto-translated per locale. |
language | string | no | BCP-47 source-content locale (e.g. es). Defaults to the project’s default_locale — authoring is not English-locked. Must be one of the project’s configured locales. Ignored on update: an existing page keeps its language. |
canonical_slug | string | no | 1–256 chars. Defaults to slug. Groups all locale variants of one page under one URL identity; set once, stable across publishes. |
media_variants | object | no | { "<data-t-media key>": { "<locale>": "<absolute URL>" } } — per-locale media overrides. Keys must match data-t-media markers in the body; URLs must be absolute http(s). Stored only when non-empty. |
schema_org_type | string | no | JSON-LD type emitted for the page. One of WebPage, Article, AboutPage, ContactPage, CollectionPage, ProfilePage. Default: WebPage. |
og_image | string | no | Absolute http(s) URL used for the page’s og:image / twitter:image. |
speakable_selector | string | no | Comma-separated CSS selectors for Speakable structured data. Default: [data-speakable], h1 + p. |
published_date | string | no | ISO 8601 date — YYYY-MM-DD, optional time suffix. Emitted as datePublished. |
updated_date | string | no | Same format. Emitted as dateModified. |
Slug rules and the document id
- Each
/-separated slug segment must be lowercase kebab-case: lowercase letters, digits, and hyphens (about,solutions/voice,blog/buying-property-spain-2026). Uppercase, underscores, and spaces are rejected. - The locale root (homepage) is the literal slug
index— a bare/is rejected. - The platform derives the internal document id from the slug: leading and trailing slashes stripped, every remaining
/becomes-— slugsolutions/voice→ document idsolutions-voice. The id is a storage detail; the URL always comes from the slug, with a trailing slash, and translated locales get their prefix from routing (/es/solutions/voice/) — never write a locale prefix into the slug itself. - After a live page is edited and republished, its document id rotates.
slug+languageis the durable handle for a page; don’t store document ids. - Reserved slug: when the project routes its live agent to a destination page, that configured slug is reserved, and uploads to it are rejected until the placement changes.
- Avoid
/live/*— it is reserved for the live-agent surface. And avoid parking custom pages under/docs/*, so they don’t collide with the documentation area; the one sanctioned exception is the platform’s own example gallery under/docs/custom-pages/examples/. Uploads to these paths are not currently blocked.
Body forms: Astro/JSX or HTML
body_source is parsed one of two ways, chosen by shape:
- Astro/JSX path — the body starts with a
---frontmatter fence, or the character sequence<Tfollowed by whitespace,/, or>appears anywhere in the source. The check is a plain text scan, not parse-aware: a literal<Tinside a JavaScript string, an HTML comment, or an attribute value still routes the whole body to the JSX parser. Longer tag names (<Title>,<Table>) do not trigger it. - HTML path — everything else: a complete HTML document or fragment.
What “delivered as authored” means, precisely:
- On the Astro/JSX path, every byte outside
<T>marker elements is preserved exactly. Translation splices text in place, and a body with zero markers is returned untouched. - On the HTML path, the body is parsed and re-serialized per locale by an HTML parser. Semantics are preserved, but markup can normalize — attribute quoting, implied elements such as
<tbody>. If exact bytes matter to you, the Astro/JSX path is the byte-exact one.
In both forms the body is static markup injected into the page shell. It is never compiled or executed server-side: no component imports, no TSX (not supported in v1), no frontmatter execution. Skip the frontmatter fence entirely — the body ships verbatim, so a --- fence would appear as literal text on the published page.
One sharp edge on the Astro/JSX path: the whole body must parse as JSX. Raw <style> or <script> blocks fail the parse as soon as their content hits a { brace, and the upload is rejected. If your page needs inline <style> or <script> blocks — most styled pages do — author the HTML form with data-t-key markers. On the JSX path, style with classes, inline style attributes, or <link rel="stylesheet">, and load behavior with <script defer src="…"></script>. The text-scan routing cuts the other way too: an HTML body whose inline script contains a literal <T (the sequence <T plus whitespace, /, or >) gets routed to the JSX parser and fails — keep that sequence out of scripts, or split it ('<' + 'T ').
Translation markers
Astro/JSX form — both of these work:
<T key="hero.title" default="Build pages from a conversation" /><T key="hero.title">Build pages from a conversation</T>HTML form — the element’s text content becomes the default:
<h1 data-t-key="hero.title">Build pages from a conversation</h1>Rules:
- Keys are static string literals matching
/^[a-z][a-z0-9_.]*[a-z0-9]$/: start with a lowercase letter, end with a letter or digit, dots and underscores only in between — minimum two characters. Namespacing likehero.title,pricing.ctakeeps large pages manageable. - Every marker needs a default: the
defaultattribute or static inner text (JSX), or non-empty plain text content (HTML). No elements nested inside a marker, no markers inside markers, no{expressions}. - The same key may appear several times with the identical default (deduplicated into one entry); two different defaults for one key are rejected.
- Marker granularity is element text. Attribute values —
alt,placeholder,aria-label— cannot carry markers and ship as authored in every locale. - The extracted key→default map is stored with the page as
translatable_strings; publish translates its values per locale. At render, the JSX<T>wrapper is replaced entirely by the locale’s string; the HTML element stays, withdata-t-keyremoved and its text replaced. A locale missing a key falls back to the source default.
Localized media
Mark any media element with data-t-media; its inline src is the default for every locale:
<img src="https://res.cloudinary.com/acme/image/upload/f_auto,q_auto/v9/tf/hero.png" alt="Product screenshot" data-t-media="hero.shot" loading="lazy" />media_variants then overrides per locale: { "hero.shot": { "es": "https://…/hero-es.png" } }. Keys must match a data-t-media marker in the body; URLs must be absolute http(s). Media URLs are authored, never machine-translated — a locale without an override renders the inline src. The data-t-media attribute itself is stripped from the published markup. Use the companion upload-media tool to host assets on the platform’s CDN with automatic format and quality optimization.
What the platform forces — and rejects
Forced on every upload:
metadata.typeis set tocustomand the page is bound to thecustom-pagetemplate. This tool cannot turn a custom page into any other page type.- The upload always lands as a draft; the live URL is untouched until publish.
Rejected, always:
- A knowledge page at the same slug. If the slug resolves to an existing markdown page, the upload is refused:
'pricing' (slug 'pricing') is a KNOWLEDGE (markdown) page, not a custom page. upload-custom-page would overwrite its body with opaque body_source. Edit it with update-page, or pick a different slug for the custom page. is_faq. The tool has no such parameter, and sneaking it into the raw arguments is detected:is_faq=true is forbidden on custom-page docs (template.forbids_is_faq=true). FAQ JSON-LD is a knowledge-page concern.- Dangerous href schemes.
javascript:,data:andvbscript:values inhreforxlink:hrefattributes — including entity- or whitespace-obfuscated forms — are rejected, and the error lists each offending href with its offset.srcattributes are unaffected, so inlinedata:images pass. - Bodies over 2 MB.
body_source is 2483911 bytes — above the 2 MB hard cap. Move large inline assets out of body_source (Cloudinary / static).Host assets withupload-mediaand reference them by URL.
Exact strings and fixes for every rejection: Custom page rejections and fixes.
Example
A landing-section upload in the Astro/JSX form — three text markers, one localizable image, per-locale media override, lint-clean:
{ "project_id": "k2xPq9wEfGhRtY3LmN8b", "project_name": "Acme Robotics", "slug": "solutions/voice", "title": "Voice agents for every channel", "description": "How Acme Robotics deploys multilingual voice agents across phone, web, and chat — with live demos and transparent pricing.", "schema_org_type": "Article", "og_image": "https://res.cloudinary.com/acme/image/upload/f_auto,q_auto/v9/tf/voice-card.png", "updated_date": "2026-06-10", "media_variants": { "hero.shot": { "es": "https://res.cloudinary.com/acme/image/upload/f_auto,q_auto/v9/tf/hero-es.png" } }, "body_source": "<section class=\"hero\" data-hero=\"true\">\n <h1><T key=\"hero.title\" default=\"Voice agents that speak every language\" /></h1>\n <p><T key=\"hero.sub\">Deploy once. Answer in thirty languages.</T></p>\n <img src=\"https://res.cloudinary.com/acme/image/upload/f_auto,q_auto/v9/tf/hero.png\" alt=\"Acme voice console\" data-t-media=\"hero.shot\" />\n <a class=\"cta\" href=\"/contact/\"><T key=\"hero.cta\" default=\"Talk to us\" /></a>\n</section>"}The success response:
{ "ok": true, "page": { "success": true, "path": "solutions-voice", "visibility": "draft", "draft_url": "<edit-mode preview link>", "next_step": "<review the draft, then publish>" }, "markers_extracted_count": 3, "lint_findings": [], "docs": { "authoring_guide": "transformento://docs/custom-pages/authoring", "troubleshooting": "transformento://docs/custom-pages/troubleshooting" }}markers_extracted_countcounts distinct text-marker keys —3here (hero.title,hero.sub,hero.cta);data-t-mediakeys are media slots, not text markers.lint_findingsis the advisory warnings array; entries never block.- On update of a live page,
pageinstead reports the draft action —draft_created_from_live,draft_updated_from_live,updated_in_place, orno_change— plus the draft’s path and adraft_urlpreview link. The live URL keeps serving until publish completes.
Common errors
title must be between 10 and 60 characters/description must be between 50 and 160 characters— count characters, spaces included; the bounds count JavaScript string length (UTF-16 code units), so an em-dash is 1 character.language 'fr' is not in project.locales [en, es]…— add the locale to the custom-pages module first, or omitlanguageto use the default.Marker extraction failed (…)— every marker problem is listed with its offset; fix each and re-upload the full body.body_source contains 1 blocked href scheme…— replacejavascript:/data:hrefs withhttps:,mailto:,tel:, relative, or#anchorURLs.'pricing' (slug 'pricing') is a KNOWLEDGE (markdown) page…— edit that page withupdate-page, or pick another slug.
Every rejection with its exact message and fix: Custom page rejections and fixes.
Related
- Custom pages overview — what custom pages are and how the pieces fit together.
- Custom page rejections and fixes — every error, exact strings, precise fixes.
- Page types — custom vs knowledge pages, and which tool edits which.
- Publishing — the publish → publish-confirm flow, manifests, and confirmation tokens.
- MCP error reference — the platform-wide error envelope and shared error codes.