Skip to content

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 publishpublish-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, new body_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

ParameterTypeRequiredConstraints / default
project_idstringyesThe project id, from list-my-projects or get-project-state.
project_namestringyesSafeguard echo-back: must match the project’s exact name (compared case-insensitively, after trimming). A mismatch rejects the call.
slugstringyes1–256 chars. URL slug in lowercase kebab-case; / nests segments; the literal index is the locale root. Full rules below.
body_sourcestringyesThe complete page source — an Astro/JSX fragment or an HTML document. Hard cap: 2 MB.
titlestringyes10–60 characters. Auto-translated per locale.
descriptionstringyes50–160 characters. Auto-translated per locale.
languagestringnoBCP-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_slugstringno1–256 chars. Defaults to slug. Groups all locale variants of one page under one URL identity; set once, stable across publishes.
media_variantsobjectno{ "<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_typestringnoJSON-LD type emitted for the page. One of WebPage, Article, AboutPage, ContactPage, CollectionPage, ProfilePage. Default: WebPage.
og_imagestringnoAbsolute http(s) URL used for the page’s og:image / twitter:image.
speakable_selectorstringnoComma-separated CSS selectors for Speakable structured data. Default: [data-speakable], h1 + p.
published_datestringnoISO 8601 date — YYYY-MM-DD, optional time suffix. Emitted as datePublished.
updated_datestringnoSame 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 - — slug solutions/voice → document id solutions-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 + language is 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 <T followed by whitespace, /, or > appears anywhere in the source. The check is a plain text scan, not parse-aware: a literal <T inside 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 like hero.title, pricing.cta keeps large pages manageable.
  • Every marker needs a default: the default attribute 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, with data-t-key removed 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.type is set to custom and the page is bound to the custom-page template. 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: and vbscript: values in href or xlink:href attributes — including entity- or whitespace-obfuscated forms — are rejected, and the error lists each offending href with its offset. src attributes are unaffected, so inline data: 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 with upload-media and 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_count counts distinct text-marker keys — 3 here (hero.title, hero.sub, hero.cta); data-t-media keys are media slots, not text markers.
  • lint_findings is the advisory warnings array; entries never block.
  • On update of a live page, page instead reports the draft action — draft_created_from_live, draft_updated_from_live, updated_in_place, or no_change — plus the draft’s path and a draft_url preview 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 omit language to 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… — replace javascript:/data: hrefs with https:, mailto:, tel:, relative, or #anchor URLs.
  • 'pricing' (slug 'pricing') is a KNOWLEDGE (markdown) page… — edit that page with update-page, or pick another slug.

Every rejection with its exact message and fix: Custom page rejections and fixes.

Last updated: