Authoring a Page
A Page is a spec: a tree of catalog components bound to your data. Compose from the catalog and the verifier accepts it; reach for the Custom escape hatch and you get the do-anything sandbox, gated behind admin.
A Page is a spec, not a file of code
A Page body is a spec — a small JSON document that describes a tree of components. You do not write a TSX file; you compose a layout from a fixed catalog of building blocks (sections, cards, headings, stats, tables, charts) and bind each one to the data it should show. The spec has a version and a single root node, and every node names a catalog component:
{
"version": 1,
"root": {
"type": "Section",
"children": [
{ "type": "Heading", "props": { "text": "Open pipeline by stage" } },
{
"type": "BarChart",
"props": { "data": { "$query": "deals_by_stage" }, "x": "stage", "y": "count" }
}
]
}
}Notice what is not here: no business_id, no API key, no fetch, no imports, no component code at all. The spec names components by type and binds them to data by query name. Everything about which rows come back is decided by the host.
The catalog
The catalog is the closed set of components a spec may use. They fall into three groups:
- Layout —
Section,Row,Grid,Card,Divider,Spacer. Containers that hold child nodes and arrange them. - Content —
Heading,Text,Markdown,Stat,StatRow,Badge,Callout,List. Prose, headings, and single-number callouts. - Data-viz —
Table,BarChart,LineChart,PieChart. The components that render a query’s rows.
A node that is not a known catalog type is rejected. Adding a new catalog component is a platform change (one catalog entry, one renderer), not something a Page author does — which is exactly what keeps the template world constrained and safe.
Page layout
A spec can carry an optional top-level layout hint that tells the renderer how to present the whole page. It never changes validation — it is pure presentation — and there are three values:
- Dashboard (the default, or
layoutabsent) — the normal multi-component page: cards, stats, and charts stacked at full width. - Single (
"single") — a single-visualization page. The one root viz (a gauge, funnel, or treemap) is rendered full-bleed, filling the canvas instead of sitting in a grid. - Document (
"document") — a long-form narrative / report page. The tree is laid out as a centered, readable prose column — for a written brief, one-pager, or battlecard built fromMarkdownplus the content components, optionally with a supporting chart. A document page can be pure prose with no declared queries at all.
Binding data
A component reads live data through a binding in its props, never by fetching. Two binding shapes exist, and both reference a query the Page declared (see Data model):
{ "$query": "<name>" }— the fullrowsarray of a declared query. This is what aTableor a chart binds itsdataprop to.{ "$value": { "query": "<name>", "field": "<col>", "row": 0 } }— a single scalar cell from a query’s rows. This is what aStatbinds its value to.
Any prop that is not a binding is a plain literal (a title string, a column key, a color). The host resolves the bindings per viewer at render time and feeds the components their data.
The Custom escape hatch
The catalog covers the common case. When you genuinely need something it doesn’t express, a node can be a Custom node: it carries bespoke TSX in its props that the host renders in the sandbox instead of a catalog component. The Custom node is the do-anything world — but it is fenced:
- It needs
pages:admin. Authoring a spec that contains a Custom node requires the admin scope. A non-admin save of a spec with a Custom node is rejected. Catalog-only specs need no special scope. - Its code runs in the sandbox.A Custom node’s TSX is transpiled and run inside the same hardened, cross-origin sandbox iframe as everything else — it can never reach the host origin, your session, or the network.
- Its code goes through the code verifier. The Custom TSX is held to the same import allowlist and forbidden-API rules a standalone component would be (see the verifier below).
Reach for catalog components first; the Custom node is the deliberate, admin-gated exception for the rare layout the catalog can’t reach.
The verifier
Every Page is checked before it can be stored. The verifier runs in stages, and a failure at any stage rejects the Page with a clear reason so you can fix it and resubmit.
- Spec shape. The body must be a valid spec:
version: 1, a singlerootnode, and every node a known catalog type (or a Custom node). An unknown type or a malformed tree is rejected here. - Bindings resolve. Every
$query/$valuebinding must reference a query the Page declared. A binding to a query name that isn’t declared is rejected. - Custom-node code. If the spec contains Custom nodes, their TSX must transpile, import only from the allowlist (
react,@amdahl/ui,recharts,lucide-react,clsx), and avoid the forbidden APIs (eval,fetch,cookie,window.parent). And the author must holdpages:admin. - Query validation. Each
source: "sql"query runs through the same SELECT-only validator as the Data Explorer (see Data model).
What gets rejected
- Unknown component type — a node whose
typeis not in the catalog (and notCustom). - Dangling binding — a
$query/$valuethat names a query the Page never declared. - Custom node without admin — a spec that uses a Custom node saved by someone who lacks
pages:admin. - Custom code violations — a Custom node whose TSX fails to transpile, pulls in a package outside the allowlist, or calls a forbidden API.
The validate dry-run
You do not have to store a Page to find out whether it passes. The validate action runs the exact same pipeline as a dry run, no write, just the verdict — including a flag for whether the spec contains any Custom nodes. This is the emit, error, fix loop: emit a draft spec, validate it, read the rejection reason, fix it, validate again, and only createonce it’s clean. Agents lean on this heavily, see Authoring via MCP; human editors get the same dry-run before publishing.
Because validate and create share one verifier, “passes validate” means “will store.” There is no second, stricter gate waiting at write time.
Once a Page is stored, you can share it beyond the console: see Embedding to drop it into an external site as a live, signed iframe.