---
title: "Custom page rejections and fixes"
description: "Every rejection upload-custom-page can return and every publish-stage failure affecting custom pages — exact messages and fixes."
url: "https://comstack.ai/docs/custom-pages/troubleshooting/"
---
## What this is

The complete catalog of rejections and failures for ComStack custom pages: every error `upload-custom-page` can return, every publish-stage failure that affects custom pages, and the advisory lint findings. Each entry gives the exact trigger, the real message text, and the fix — written so a developer or an agent can self-correct from the message alone. The happy path is documented in [Author a custom page](/docs/custom-pages/authoring.md).

## How it works

Custom-page failures surface at two stages, plus one channel that never blocks:

1. **Upload time.** `upload-custom-page` validates synchronously and rejects the whole call with a parameter error. Nothing is stored on rejection; an audit row records the attempt with an error code such as `marker_extraction_failed` or `blocked_href_scheme`.
2. **Publish time.** `publish` (the dry run) refuses to mint a confirmation token for ambiguous draft states. `publish-confirm` runs asynchronously, so later failures — the Lighthouse gate, the site build — surface when you poll `publish-status` as `status: "failed"` with the error text.
3. **Lint — never.** Lint findings come back in the upload response as `lint_findings` with `severity: "warning"`. They are advisory only and never block an upload or a publish. They earn their place because they predict Lighthouse Performance failures, which do block when the gate runs.

Messages below are quoted as the implementation emits them, with example values standing in for the variable parts.

## When to use it

- `upload-custom-page` returned an error and you want the precise fix, not a guess.
- `publish` refused to produce a confirmation token, or `publish-status` reports `failed`.
- A draft you expected to publish is missing from the publish manifest.
- You are deciding whether `lint_findings` need fixing before you publish.

## Upload-time rejections

### Title and description bounds

**Trigger:** `title` shorter than 10 or longer than 60 characters; `description` shorter than 50 or longer than 160.

```text
title must be between 10 and 60 characters
description must be between 50 and 160 characters
```

**Fix:** count characters, spaces included, and rewrite within bounds. On multilingual sites, machine-translated titles and descriptions that run long are hard-cropped to 60/160 characters with a trailing `…` — leave headroom if you can.

### project_name mismatch

**Trigger:** `project_name` does not match the project's actual name (the comparison is case-insensitive, after trimming whitespace).

```text
project_name does not match project k2xPq9wEfGhRtY3LmN8b: expected 'Acme Robotics', got 'Acme'
```

**Fix:** copy the name verbatim from `get-project-state`. The echo-back is a safeguard so a destructive call can never run against the wrong project.

### language not in the project's locales

**Trigger:** `language` names a locale the project is not configured for.

```text
language 'fr' is not in project.locales [en, es]. Add it to modules.custom_pages.locales first, or omit `language` to use the default_locale ('en').
```

**Fix:** the message is self-correcting — add the locale to the custom-pages module, or drop the `language` argument. Remember that `language` is the page's *source* locale, not a translation request (publish translates automatically), and that it is ignored on updates: an existing page keeps its language.

### Slug validation

**Trigger:** any `/`-separated slug segment that is not lowercase kebab-case, or a bare `/`.

```text
Slug must be lowercase kebab-case. Use lowercase letters, digits, and hyphens. Use '/' to nest path segments. Use 'index' for the locale root.
```

**Fix:** valid examples: `index`, `guides/buying`, `blog/buying-property-spain-2026`. The locale root is the literal slug `index`, never `/`.

A second, rarer slug rejection — when the project's live agent is placed on a destination page, that one slug is reserved:

```text
Slug 'talk' is reserved by settings/site.live_agent_slug while live_agent_placement is 'destination_page'.
```

**Fix:** as the error's fix text says — pick a different slug, or move the live-agent placement away from `destination_page` to release it.

### Marker extraction failures

**Trigger:** any invalid translation marker in `body_source`. The upload is rejected wholesale, and every problem is listed at once:

```text
Marker extraction failed (2 errors):
  1. offset=118 line=4 col=7 key='Hero.Title' — Invalid marker key 'Hero.Title'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).
  2. offset=240 line=7 col=5 — <T> marker is missing required `key` attribute.
```

The individual messages:

| Trigger | Exact message |
|---|---|
| Key fails the pattern — uppercase, leading digit, trailing `.` or `_`, or a single character (two is the minimum) | ``Invalid marker key 'Hero.Title'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).`` |
| `<T>` without a `key` attribute | ``<T> marker is missing required `key` attribute.`` |
| `key={expr}` instead of a string literal | ``<T key={...}> dynamic key expressions are not allowed. Use a static string literal.`` |
| `default={expr}` instead of a string literal | ``<T default={...}> dynamic defaults are not allowed. Use a static string literal.`` |
| No `default` attribute and no inner text | ``<T key='hero.cta'> is missing a default. Provide a static `default` attribute or static inner text.`` |
| `{expression}` inside a `<T>` element | ``<T>{expression}</T> dynamic inner content is not allowed. Use a static default attribute or static text.`` |
| A fragment inside a `<T>` element | ``<T> markers may not contain fragments. Use a static default attribute or static text.`` |
| `<T>` nested inside `<T>` | ``Nested <T> markers are not allowed. A marker cannot contain another marker.`` |
| Body routes to the JSX parser but is not valid JSX | ``JSX/Astro parse error: Unexpected token (8:20). The body_source must be parseable JSX/Astro embedded inside a fragment.`` |
| `data-t-key` element containing child elements | ``Marker 'hero.title' contains nested HTML elements. Marker elements must wrap plain text only.`` |
| `data-t-key` element with empty text | ``Marker 'hero.title' has empty text content. The element's text becomes the English default; it must be non-empty.`` |
| `data-t-key` nested inside another `data-t-key` element | ``Nested data-t-key markers are not allowed. 'inner.key' is inside marker 'outer.key'.`` |
| Same key used twice with different defaults (both forms) | ``Duplicate marker key 'hero.cta' with conflicting default. Existing default: 'Talk to us'. New default: 'Contact us'. Use one default per key.`` |

**Fixes:**

- Keys: start with a lowercase letter, end with a letter or digit, dots and underscores only in between, minimum two characters. `hero.title` passes; `Hero`, `1cta`, `nav.`, and `x` are all rejected.
- The JSX parse error usually means an inline `<style>` or `<script>` block on the Astro/JSX path — their `{` braces are not valid JSX. Routing to that parser is a plain text scan, not parse-aware: a body that starts with `---` frontmatter, or that contains the sequence `<T` followed by whitespace, `/`, or `>` anywhere — even inside a JavaScript string or an HTML comment — is parsed as JSX. So an HTML body whose inline script contains a literal `<T ` trips this error too; remove the sequence or split it (`'<' + 'T '`). Otherwise: author the page in the HTML form (`data-t-key` markers), or move styles and scripts out of the body. The `(line:col)` in the message points at the first offending character.
- A key may repeat only with the identical default. Deduplicate, or rename one of the keys.
- Markers wrap plain text only — restructure nested markup into several sibling markers.
- The "English default" wording is literal in the message; strictly it is the source-locale default (whatever your `language` resolves to).

### Blocked href schemes

**Trigger:** any `href` or `xlink:href` attribute whose value resolves to `javascript:`, `data:`, or `vbscript:`. Values are normalized before the check — HTML entities decoded, embedded whitespace and control characters stripped — so obfuscated forms like `java&#x73;cript:` or a tab inside the scheme are caught. `src` attributes are not checked: inline `data:` images keep working. The attempt is recorded in the audit trail with error code `blocked_href_scheme`.

```text
body_source contains 1 blocked href scheme — javascript:, data: and vbscript: hrefs are rejected (XSS hard boundary; the body ships to the browser as authored):
  1. offset=412 scheme=javascript: href="javascript:alert(1)"
Use https:, mailto:, tel:, relative or #anchor URLs instead. See transformento://docs/custom-pages/authoring.
```

**Fix:** as the message says. For click behavior, attach a listener from a deferred script instead of a `javascript:` URL.

### Wrong tool for the page type

**Trigger:** the slug resolves to an existing knowledge (markdown) page. The rejected attempt is recorded in the audit trail with error code `wrong_tool_knowledge_page`.

```text
'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.
```

**Fix:** exactly as stated — edit that page with `update-page`, or choose a different slug. The guard is symmetric: `update-page` on a custom page rejects too, with

```text
'solutions-voice' is a CUSTOM page (metadata.type="custom"). Edit it with upload-custom-page (body_source + <T key=…> markers), not update-page. update-page edits knowledge (markdown) pages only.
```

When unsure, `get-page-content` returns `page_type` — `"custom"` or `"knowledge"` — so you can pick the right editor before writing. See [Page types](/docs/mcp/page-types.md).

### is_faq is forbidden

**Trigger:** passing `is_faq: true`. The tool has no such parameter, and sneaking it into the raw arguments is detected.

```text
is_faq=true is forbidden on custom-page docs (template.forbids_is_faq=true). FAQ JSON-LD is a knowledge-page concern.
```

**Fix:** put FAQ content on a knowledge page. Custom pages never emit FAQ structured data.

### Body over the 2 MB hard cap

**Trigger:** `body_source` larger than 2 MB (2,097,152 bytes of UTF-8).

```text
body_source is 2483911 bytes — above the 2 MB hard cap. Move large inline assets out of body_source (Cloudinary / static).
```

**Fix:** host images, video, and fonts with `upload-media` (or any CDN) and reference them by URL. The `body-too-large` lint warning fires earlier, at 500 KB — treat it as the canary.

### media_variants validation

**Trigger:** a malformed `media_variants` map. Four distinct rejections:

- Wrong overall shape:

```text
media_variants must be an object map of { "<key>": { "<locale>": "<absolute URL>" } }.
```

- A key without a matching marker in the body:

```text
media_variants key 'hero.shot' has no matching data-t-media marker in the page body. Add data-t-media="hero.shot" to the <img|video|source> element, or remove the override.
```

- A locale map that is not an object:

```text
media_variants['hero.shot'] must be a { "<locale>": "<absolute URL>" } object.
```

- A relative or non-http URL:

```text
media_variants['hero.shot']['es'] must be an absolute http(s) URL (got /img/hero-es.png).
```

**Fix:** every key in `media_variants` must match a `data-t-media` attribute in the body, and every URL must be absolute http(s). The map is optional — locales without an entry render the element's inline `src`.

### Other metadata validation

- `og_image` must be an absolute http(s) URL.
- `published_date` and `updated_date` must be ISO 8601 dates — `YYYY-MM-DD`, optional time suffix.
- `schema_org_type` must be one of `WebPage`, `Article`, `AboutPage`, `ContactPage`, `CollectionPage`, `ProfilePage`.

These come back as field-named validation issues with the allowed values listed; fix the named field and retry.

## Publish-stage failures

### duplicate_draft_target

**Trigger:** at `publish` (dry run), two or more pending drafts target the same `(slug, language)` URL — typically a leftover draft plus a newer one. No confirmation token is minted.

```text
duplicate_draft_target: 1 (slug, language) target(s) have more than one pending draft. No confirmation token was minted. 'solutions/voice' (en) → solutions-voice, k9XbQ2mEphd71GfTzALw. Discard the extra drafts (discard-draft) or change a slug, then publish again.
```

The structured error data lists each collision and marks exactly one draft `newest: true`.

**Fix:** keep the draft marked newest and `discard-draft` the others — or change a slug if both pages should exist — then run `publish` again.

### A draft missing from the manifest: zero-diff

**Trigger:** not an error. A draft whose content is identical to the live page is excluded from the publish manifest and **deleted, not promoted** at publish time. Editing a live page with byte-identical content does not even create a draft — the upload response reports `"action": "no_change"`.

**Fix:** nothing to fix. If you expected changes, diff what you uploaded against the live page; you probably re-uploaded the current version.

### Confirmation token expired

**Trigger:** more than 5 minutes between `publish` and `publish-confirm`.

```text
Confirmation token has expired (5-minute TTL). Call publish again to get a fresh token, then call publish-confirm immediately.
```

**Fix:** as stated. Tokens are also single-use and bound to the user, the project, and the exact manifest — if the draft set changed after the dry run, the token is invalidated and you must `publish` again.

### slug_in_use on re-upload of a published page

**Trigger:** a known rough edge in the upsert. `upload-custom-page` finds an existing page by the document id it derives from the slug. The first upload creates the page at that id and the first publish keeps it — uploads keep working. But the first time you edit and republish a live custom page, its document id rotates; the *next* upload for that slug finds nothing at the derived id, takes the create path, and trips the slug-collision guard against the rotated live page:

```text
Slug 'solutions/voice' (language 'en') is already used by another page (path: 'k9XbQ2mEphd71GfTzALw', visibility: live). Two pages with the same slug + language would deploy to the same URL.
```

The error's suggested fix — `update-page` — does **not** work for custom pages; it rejects them (see "Wrong tool for the page type" above).

**Fix, today:** call `delete-page` with the `path` named in the error (the rotated live document), re-run the same `upload-custom-page` call, then publish. The deployed site keeps serving the existing page until the publish completes, so visitors see no gap — but re-upload immediately after the delete, since the deletion removes the stored page at once. In short: expect every upload to work until a page has been republished once; the upload after that republish hits this error until the platform closes the edge.

### Lighthouse gate failure — and force

**Trigger:** custom pages are audited at pre-publish against a bar of **95 in all four categories** — Performance, Accessibility, Best Practices, SEO — when the platform's quality gate runs. Any category below 95 on any audited custom page fails the publish: `publish-status` reports `status: "failed"` with

```text
Lighthouse pre-publish gate failed: https://www.acme-robotics.com/solutions/voice scored below 95 (perf=82, a11y=98, bp=100, seo=100)
```

Multiple failing URLs are joined with `|`. Each failing URL also carries a `remediation` list — the specific failing audits as `auditId: title — description` — naming exactly what to fix.

**Fix:** build to ≥95; that is the contract. The upload-time lint rules map to the most common Performance failures — eager scripts, non-lazy images, render-blocking stylesheets, fonts without `font-display: swap`, unoptimized images. Fix, re-upload, publish again.

**force:** `publish-confirm` accepts `force: true`, which lets a below-bar score through. It is never silent: the findings record `Lighthouse gate forced past failing scores via force:true. Audit warning will be recorded.`, the publish record is stamped as forced, and an audit entry is written. Use it for genuine emergencies, then fix the page.

### Build-time substitution failure

**Trigger:** rare — a stored body that no longer parses when translations are spliced in at site build (in practice only possible if content was modified outside the tool). The build fails loudly and the publish fails with it:

```text
custom-page substitute: body_source did not parse as JSX/Astro. Re-extract markers and re-upload.
```

**Fix:** re-run `upload-custom-page` with the full body — upload-time extraction will list the actual parse problems — then publish again.

## Lint findings — warnings, never blockers

Returned in the upload response as `lint_findings`, each shaped `{ rule, severity, line, column, message, fix_hint }`. Every finding is `severity: "warning"`; nothing here blocks an upload or a publish. Each rule predicts a Lighthouse Performance failure, which *does* block when the gate runs — so fix them anyway.

| Rule | Trigger | Fix |
|---|---|---|
| `body-too-large` | `body_source` over 500 KB (soft cap; the hard rejection is at 2 MB) | Move inline assets out to `upload-media` / CDN URLs |
| `no-eager-script` | `<script>` without `defer` or `async` (JSON-LD, templates, and `type="module"` are exempt) | Add `defer` (preferred) or `async` |
| `img-not-lazy` | `<img>` without `loading="lazy"` — suppressed when `data-hero` appears within the preceding 4 KB of markup | Add `loading="lazy"`, or mark the hero/LCP image's container with `data-hero` |
| `blocking-stylesheet` | `<link rel="stylesheet">` with no `media` attribute and not preloaded | Use `media="print" onload="this.media='all'"`, or a preload-then-apply pattern |
| `no-font-display-swap` | `@font-face` block without `font-display: swap` | Add `font-display: swap;` to the block |
| `cloudinary-not-auto` | A Cloudinary URL missing `f_auto` and/or `q_auto` (the message names whichever is missing) | Insert `f_auto,q_auto/` after `/upload/` — the platform never rewrites your URLs |

## Example

A self-correcting round trip. First attempt:

```json
{
  "project_id": "k2xPq9wEfGhRtY3LmN8b",
  "project_name": "Acme Robotics",
  "slug": "about",
  "title": "About Acme Robotics",
  "description": "Who builds Acme's multilingual voice agents — the team, the mission, and how to reach us.",
  "body_source": "<section>\n  <h1 data-t-key=\"Hero\">We teach robots to listen</h1>\n</section>"
}
```

Rejected — the key starts with an uppercase letter:

```text
Marker extraction failed (1 error):
  1. offset=12 key='Hero' — Invalid marker key 'Hero'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).
```

Change `data-t-key="Hero"` to `data-t-key="about.hero"` and re-upload the full body:

```json
{
  "ok": true,
  "page": { "success": true, "path": "about", "visibility": "draft", "draft_url": "<edit-mode preview link>", "next_step": "<review the draft, then publish>" },
  "markers_extracted_count": 1,
  "lint_findings": [],
  "docs": {
    "authoring_guide": "transformento://docs/custom-pages/authoring",
    "troubleshooting": "transformento://docs/custom-pages/troubleshooting"
  }
}
```

## Common errors

The five most frequent, one line each:

1. `title must be between 10 and 60 characters` — count characters, spaces included; same idea for the 50–160 description.
2. `language 'fr' is not in project.locales [en, es]. …` — add the locale to the custom-pages module, or omit `language`.
3. `Invalid marker key '…'` — lowercase start, alphanumeric end, dots and underscores inside, minimum two characters.
4. `body_source contains 1 blocked href scheme …` — no `javascript:`/`data:`/`vbscript:` hrefs; use `https:`, `mailto:`, `tel:`, relative, or `#anchor`.
5. `Confirmation token has expired (5-minute TTL). …` — run `publish` again and confirm immediately.

## Related

- [Custom pages overview](/docs/custom-pages.md) — what custom pages are and how the pieces fit together.
- [Author a custom page](/docs/custom-pages/authoring.md) — the full parameter contract and a worked example.
- [Page types](/docs/mcp/page-types.md) — which tool edits which page type.
- [Publishing](/docs/mcp/publishing.md) — manifests, confirmation tokens, and publish-status.
- [MCP error reference](/docs/mcp/errors.md) — the platform-wide error envelope.
