Page
A routed container whose element tree is what visitors see at a URL.
A Page is a routed tree inside an app. Metadata (name, segment, role, publish state) plus an element tree. The published URL is not stored as a string — it is the chain of routePattern values walked down parentId.
The page tree is rooted at body. Add children under that root. Elements are nodes; props are attributes.
Fields
| Name | Type | Default | Description |
|---|---|---|---|
routePattern | string | derived | This node's segment, not a URL. "about", "events/$id", or "" for an index leaf. No leading slash. $name segments become {{page.params.*}}. |
type | "page" | "layout" | "page" | "page" is a leaf that renders content. "layout" is a named folder that wraps children through exactly one codelab.outlet. Layouts never match a URL alone. |
parentId | string | empty | empty | The layout this node nests under, or empty at the root. Only a layout can be a parent. |
isHome | boolean | — | The app's home. Every app has exactly one. |
status | "draft" | "published" | "draft" | Draft is not on the published site. |
visibility | "public" | "private" | "public" | Gating after publish. Ignored while draft. Private pages run accessPolicy; a failed gate looks like a 404. |
fields | field list | — | Author-declared props for this page. Route facts (id, appId, params, search) are available as {{page.*}} without being listed here. |

A new page always gets a body root. Add children under that root with add_element — do not call add_root_element on a page.
A layout must contain one codelab.outlet (and only one). Without it, nested pages have nowhere to render. Promoting a page to "layout" does not insert the outlet — add a codelab.outlet primitive yourself, then set children's parentId.
The layout's own URL is an index child: a "page" under that layout with routePattern: "". Empty is not a layout path; a layout must have a segment ("dashboard", "events").
Inside the page tree, {{props.*}} is this page's props (entering a component replaces that frame). Ambient route facts are also {{page.id}}, {{page.appId}}, {{page.params.*}}, {{page.search.*}}.
How it relates
- App —
list_pages/create_pagerun on the active app fromset_context. - Layout pages — shared chrome (header, nav, footer) plus
codelab.outlet. Leaves nest withparentId. - Elements — the page is an element tree. Pass
container: { _tag: "Page", id: pageId }onadd_element/move_element/remove_element. - Components — drop in as elements with kind
{ _tag: "Component", value: componentId }. Bind instance values withupdate_propson that node.
MCP tools
All of these need contextId except the outline/search tools that take a pageId (those check you own the page).
| Tool | What you use it for |
|---|---|
list_pages | Lightweight list: id, name, routePattern, type, parentId, isHome. No elements. Rebuild the tree from parentId. |
create_page | { name, routePattern?, type?, parentId?, isHome? }. Omit routePattern to derive from the name — omit is not "". "" is an index page. |
update_page | Partial: name, status, visibility, type, parentId, routePattern, accessPolicy, SEO, loader. Omitted fields stay. Not isHome — promoting home demotes the previous one. |
delete_page | Cascades the page's elements. Cannot delete the home page or the last remaining page. |
load_page_outline | Indented tree (kind, id, aria-label, styles/props when set). Heavy — prefer search when you know the target. |
find_element_in_page | Search by aria-label, text, or kind → elementIds. |
Then mutate the tree with the element tools, passing container: { _tag: "Page", id: pageId }.
Layout workflow:
create_pagewithtype: "layout"and a segment (routePattern: "dashboard").- Add chrome plus one
codelab.outletunder body. create_pagean index leaf withroutePattern: ""andparentIdof the layout.update_pageother leaves to the sameparentId.
Gotchas
- Segment vs URL.
"events/$id"is this node's piece. The published path is the parent chain (/events/42). Re-parenting rewrites descendant URLs for free. ""vs omittedroutePattern. Omit → auto-derive from the name ("About"→"about"). Pass""only for an index leaf. A layout cannot use"".- Outlet is required, not inferred.
type: "layout"withoutcodelab.outletis rejected on placement. Exactly one outlet. - Draft is invisible. New pages are
draft+public. Publish withupdate_page{ status: "published" }or the live site 404s. - Same add-element rules as everywhere.
aria-labelfor purpose, omittargetPositionto append,inspect_element_typebefore Radio / Field / Dialog / Menu. See Element. - Do not
add_root_elementon a page. Thebodyroot already exists.