Pluma
Docs index
docs/guides/composable-pages.md View markdown →

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:

  1. 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.
  2. 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.
  3. A route /[slug] that requests the page entries with include=2 and rich_text=html: they come with their sections, the price_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.

  1. Each section component marks its root with the content type it draws: <section data-pluma-type="section_pricing">.
  2. 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, then npx 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 /tuition and 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.