Composable pages
With this, a new page on the site ("Tuition", "Our mission") gets built by chat, without touching code: your agent creates the page in Pluma with sections the site already knows how to render, publishes it, and it shows up at /<slug>.
Pluma has no built-in "pages", on purpose: the design belongs to your site. This is a model pattern: two content types and one component per section. Everything is custom: each site defines its own sections.
When it helps and when it doesn't
| You want… | How |
|---|---|
| To change a text, a price, an event | Already works by chat, without this: you edit the entry |
| A new page with sections that already exist | By chat, with this pattern |
| A new kind of section ("Video testimonials") | Model by chat, plus one component on the site, once |
| To change a page that is written in the site's code | Code: Pluma doesn't build those pages |
The model
One type per section. Each one with the fields it needs and its agent guidance: when to use it, when not to, and previews of how it looks. That's what lets the agent choose well.
| Type | Fields | Guidance (summary) |
|---|---|---|
section_hero |
eyebrow, title, text, image, buttonLabel, buttonUrl |
Once, at the very top |
section_text |
title, body (rich text) |
Explanations longer than two sentences |
section_statement |
eyebrow, statement, attribution |
A single big idea: mission, motto |
section_pricing |
title, intro, items (→ price_item), note |
Tuition, fees, discounts |
section_cta |
title, text, buttonLabel, buttonUrl |
Once, at the end: the next action |
A page type that puts them in order:
| Field | Type | What for |
|---|---|---|
title |
symbol |
Title for the browser tab and Google |
slug |
slug |
The URL: tuition → /tuition |
seoDescription |
symbol |
One sentence for Google |
sections |
references to the section_* types |
The sections, in reading order |
In the page type's guidance, tell the agent three things it can't guess:
- the slugs the code's pages already use (
about,apply,blog…), so it doesn't reuse them; - that a new page doesn't get added to the menu on its own: if the menu lives in Pluma, where it is;
- whether the layout already ends every page with a call to action, so it doesn't duplicate it.
The site
Three pieces, and none of them change when a new page is built:
- One component per section, with the same name as its type in Pluma, drawn with the components the site already has. That way the new page looks native.
- A dispatcher that picks the component by type (
sys.content_type) and skips, with a warning in the build, the types it doesn't know. It never guesses. - A route
/[slug]that requests thepageentries withinclude=2andrich_text=html: they come with their sections, theprice_items and the images in the same response. If a page in the code already uses the slug, the code's page wins.
In Astro:
---
// src/pages/[slug].astro
export async function getStaticPaths() {
const taken = new Set(Object.keys(import.meta.glob('./*.astro')).map((f) => f.slice(2, -6)))
const pages = await getPages() // GET /entries?content_type=page&include=2&rich_text=html
return pages.filter((p) => !taken.has(p.slug)).map((page) => ({ params: { slug: page.slug }, props: { page } }))
}
const { page } = Astro.props
---
<Layout title={page.title} description={page.seoDescription}>
{page.sections.map((section) => <Section section={section} />)}
</Layout>
On the test site it's three files: src/pages/[slug].astro, one component per section in src/components/sections/, and getPages() in src/lib/pluma.mjs. See also the Astro recipe.
How the owner asks for it
"Add a Tuition page with the prices for the 2026-2027 school year and, below, our mission. At the end, invite people to schedule a visit."
The agent reads the guidance with describe_space, creates the price_items, the sections and the page in one batch (with "$ref" to link them without knowing the ids), publishes it, and the deploy hook rebuilds the site.
What we tested
On the pilot site, an agent with no context, only the MCP, got the request above in one line. It read the site, built the full page (4 prices, hero, pricing, mission and closing) and tested it with dry_run. Then it published it in a single batch of 18 operations: 13 MCP calls in total. The site rendered it at /tuition with its own components, without touching code, and the 97 pages that were already there stayed identical.
What the agent couldn't do, as expected:
- Edit a page in the code. They also asked for the mission on About, which is code. The agent put it in a text block used by two pages and published it, telling them afterwards. We fixed it like this: the MCP now tells it to ask before publishing a change the owner didn't name or that shows up in more than one place, and the site's blocks have a "shown on" field listing their pages, which the type's guidance tells it to check.
- Add the page to the menu. Nobody asked, and the menu was a JSON field.
- See the result. No tool reports the published URL or whether the deploy finished.
Previews that stay current
The previews in each type's guidance come from the real site, and a script keeps them current: run it after each staging deploy and the agent always sees what a section looks like today.
- Each section component marks its root with the content type it draws:
<section data-pluma-type="section_pricing">. - The script opens the pages you give it, screenshots the first element of each type, and uploads it as the file Preview: <type>. On later runs it replaces that same file, so nothing piles up and nothing published is rebuilt. It also makes sure the type lists it in its previews.
// scripts/pluma-previews.mjs — PLUMA_SPACE=… PLUMA_MANAGEMENT_KEY=… node scripts/pluma-previews.mjs <page URL> [more]
import { chromium } from "playwright"
const { PLUMA_SPACE, PLUMA_MANAGEMENT_KEY, PLUMA_API_URL = "https://pluma.so" } = process.env
const api = `${PLUMA_API_URL}/api/v1/spaces/${PLUMA_SPACE}`
const auth = { Authorization: `Bearer ${PLUMA_MANAGEMENT_KEY}` }
async function pluma(method, path, body) {
const json = body && !(body instanceof FormData)
const res = await fetch(api + path, { method, headers: { ...auth, ...(json ? { "Content-Type": "application/json" } : {}) }, body: json ? JSON.stringify(body) : body })
const data = await res.json()
if (!res.ok) throw new Error(`${method} ${path}: ${data.error.message} ${data.error.fix}`)
return data
}
const shots = new Map()
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
for (const url of process.argv.slice(2)) {
await page.goto(url, { waitUntil: "networkidle" })
for (const el of await page.$$("[data-pluma-type]")) {
const type = await el.getAttribute("data-pluma-type")
if (!shots.has(type)) shots.set(type, await el.screenshot())
}
}
} finally {
await browser.close()
}
const { items: files } = await pluma("GET", "/assets?limit=1000")
for (const [type, png] of shots) {
const title = `Preview: ${type}`
const form = new FormData()
form.append("file", new Blob([png], { type: "image/png" }), `preview-${type}.png`)
form.append("title", title)
form.append("alt", `How a ${type.replace(/_/g, " ")} looks on the site`)
const existing = files.find((f) => f.fields.title === title)
const file = existing ? await pluma("PATCH", `/assets/${existing.sys.id}`, form) : await pluma("POST", "/assets", form)
const ids = ((await pluma("GET", `/content_types/${type}`)).previews ?? []).map((p) => p.id)
if (!ids.includes(file.sys.id)) await pluma("PATCH", `/content_types/${type}`, { previews: [file.sys.id, ...ids].slice(0, 4) })
}
- It needs a management key (it writes files and the model) and Playwright (
npm i -D playwright, thennpx playwright install chromium). - Point it at a page that uses every section type, on staging or on a local build. In the pilot it captured the 4 sections of
/tuitionand replaced the same 4 files on the second run. - Run it wherever you run things after a deploy: a CI step, a Netlify post-deploy plugin, or by hand after changing a section's design.