---
title: "Author a custom page"
description: "The complete upload-custom-page contract: every parameter, slug and upsert rules, body forms, translation markers, and a worked example."
url: "https://comstack.ai/docs/custom-pages/authoring/"
---
## 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](/docs/mcp/page-types.md)).

## 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](/docs/mcp/publishing.md)). 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](/docs/custom-pages/troubleshooting.md) 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

| 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 `-` — 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:

```text
<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:

```html
<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:

```html
<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](/docs/custom-pages/troubleshooting.md).

## Example

A landing-section upload in the Astro/JSX form — three text markers, one localizable image, per-locale media override, lint-clean:

```json
{
  "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:

```json
{
  "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](/docs/custom-pages/troubleshooting.md).

## Related

- [Custom pages overview](/docs/custom-pages.md) — what custom pages are and how the pieces fit together.
- [Custom page rejections and fixes](/docs/custom-pages/troubleshooting.md) — every error, exact strings, precise fixes.
- [Page types](/docs/mcp/page-types.md) — custom vs knowledge pages, and which tool edits which.
- [Publishing](/docs/mcp/publishing.md) — the publish → publish-confirm flow, manifests, and confirmation tokens.
- [MCP error reference](/docs/mcp/errors.md) — the platform-wide error envelope and shared error codes.
