<!-- docs/api/index.md -->

# API · conventions

Everything you do in the dashboard can be done by API. This page explains what applies to every endpoint.

## Base

```
https://pluma.so/api/v1
```

Start at the root. It tells you what it is, what your key can do and where the docs are:

`GET /api/v1`

```sh
curl https://pluma.so/api/v1 -H "Authorization: Bearer $PLUMA_KEY"
```

```json
{
  "name": "Pluma",
  "docs": "https://pluma.so/llms.txt",
  "key": { "kind": "delivery", "space": "acton-estero", "can": ["read_published"] },
  "next": "GET /api/v1/spaces/acton-estero"
}
```

The machine contract (OpenAPI 3.1) is at [`/openapi.json`](/openapi.json): use it to generate clients or to let your agent see every endpoint with its parameters.

## Authentication

Every request carries a key in the header:

```
Authorization: Bearer pluma_dlv_…
```

Keys are created in the site dashboard, under **Keys**. The full value is shown **only once**; keep it in a secrets manager. You can revoke a key in one click.

| Kind | Prefix | What it can do | What for |
| --- | --- | --- | --- |
| `delivery` | `pluma_dlv_` | Read what is **published** | Your site's build. Safe to use on the build server |
| `preview` | `pluma_prv_` | Also read **drafts** | Preview |
| `management` | `pluma_mgt_` | Read everything, **write**, publish and change the model | Scripts, migrations. Never in the browser |

An invited **agent** has its own token (`pluma_agt_`) with the same permissions as `management`, plus creating `delivery` and `preview` keys. See [Agents](agents.md).

**Delivery and Preview are the same API:** same routes, same responses. The only thing that changes is the key: with a `preview` key you see the latest version of each entry; with a `delivery` key, the published one.

## Responses

- JSON, UTF-8, `snake_case` in the API's names; your content's fields use their API ID as is.
- Every object has `sys` (metadata: `id`, `type`, dates, version) and `fields` (your content).
- The `sys` dates (`created_at`, `updated_at`, `published_at`) are ISO 8601, UTC. Date fields in your content come back exactly as you saved them, with their time zone.
- `id`s are opaque and unique across all of Pluma, not per site: don't assume they start at 1 or are consecutive.

## Collections and pagination

```json
{ "sys": { "type": "Array" }, "total": 57, "skip": 0, "limit": 100, "items": [ … ] }
```

- `limit`: up to 1000. Default 100.
- `skip`: how many to skip.
- For the next page: `skip = skip + limit` while `skip < total`.

## Locales

- Without `locale`, fields come in the site's default language.
- `locale=en`: in that language; if a field has no value, it falls back to the fallback language. Fields that aren't localized always come with the default language's value.
- `locale=*`: every language, as `{ "title": { "es": "…", "en": "…" } }`.
- Filters (`fields.slug=…`), sorting and `sys.url` use the language you asked for.
- Writing takes the same `locale`. See [Languages](../guides/languages.md).

## Errors

Every error says what happened, **how to fix it** and links to its page:

```json
{
  "error": {
    "code": "invalid_key",
    "message": "The key does not exist or was revoked.",
    "fix": "Check that you copied all of it (it starts with pluma_). If it was revoked, create a new one in Keys.",
    "doc_url": "https://pluma.so/docs/errors/invalid_key"
  }
}
```

The full list is in [Errors](../errors/index.md).

## Cache

- With a `delivery` key: `Cache-Control: public, max-age=60, s-maxage=300` and `ETag`. Send `If-None-Match` and you get `304` if nothing changed.
- With `preview` or `management`: `Cache-Control: private, no-store`.

## Limits

- **Per token:** up to **600 requests per minute** (API and MCP together). Every response has `X-RateLimit-Limit` and `X-RateLimit-Remaining`. If you go over, you get [`rate_limited`](../errors/rate_limited.md) with `Retry-After` in seconds.
- **Per site:** 1 million API calls per month. Today they are counted; the warning and the limit arrive with billing.

---

<!-- docs/errors/index.md -->

# Errors

Every API error has this shape:

```json
{ "error": { "code": "…", "message": "…", "fix": "…", "doc_url": "…", "details": { } } }
```

- `code` never changes: it is what your code compares against.
- `message` and `fix` are for people and agents: what happened and how to fix it.
- `details` (optional) points to the field or parameter at fault.

| Code | HTTP | What happened |
| --- | --- | --- |
| [`missing_key`](missing_key.md) | 401 | The key is missing. |
| [`invalid_key`](invalid_key.md) | 401 | The key does not exist or was revoked. |
| [`wrong_space`](wrong_space.md) | 403 | This key belongs to another site. |
| [`key_cannot`](key_cannot.md) | 403 | This key cannot do that. |
| [`not_found`](not_found.md) | 404 | It does not exist. |
| [`invalid_parameter`](invalid_parameter.md) | 400 | A parameter is not valid. |
| [`validation_failed`](validation_failed.md) | 422 | The data did not pass validation. |
| [`version_conflict`](version_conflict.md) | 409 | Someone saved another version in the meantime. |
| [`invalid_body`](invalid_body.md) | 400 | The request body is not valid JSON. |
| [`invite_used`](invite_used.md) | 410 | This invite link has already been used. |
| [`invite_expired`](invite_expired.md) | 410 | This invite link has expired. |
| [`check_unavailable`](check_unavailable.md) | 422 | The readiness check couldn't run. |
| [`translation_unavailable`](translation_unavailable.md) | 503 | The translation with AI couldn't run. |
| [`rate_limited`](rate_limited.md) | 429 | Too many requests with this token. |

---

<!-- docs/frameworks/astro.md -->

# Astro

How an Astro site reads its content from Pluma **without changing its pages**, with the repo's files as a fallback. It's the pattern from the pilot, with a real 97-page school site: the build from Pluma came out with HTML identical to the build from files.

The examples below are **simplified** so the idea is clear. On the tested site the full code lives in two files, `src/lib/pluma.mjs` and `src/content.config.ts`. Requires Astro 5 (content layer).

## The idea

- Everything that talks to Pluma lives in **one file** (`src/lib/pluma.mjs`).
- If `PLUMA_SPACE` and `PLUMA_DELIVERY_KEY` are set, the collections read from Pluma. If not, from `src/content/**` as always.
- If the variables are set and Pluma fails, **the build stops**. It doesn't silently fall back to the files: that would publish old content that looks healthy.

## 1. The client

```js
// src/lib/pluma.mjs
const env = process.env
export const PLUMA_ENABLED = Boolean(env.PLUMA_SPACE && env.PLUMA_DELIVERY_KEY)
const API = (env.PLUMA_URL || "https://pluma.so").replace(/\/$/, "")

async function get(pathAndQuery) {
  const res = await fetch(`${API}/api/v1/spaces/${env.PLUMA_SPACE}${pathAndQuery}`, {
    headers: { Authorization: `Bearer ${env.PLUMA_DELIVERY_KEY}` },
  })
  const body = await res.json().catch(() => null)
  if (!res.ok) {
    const e = body?.error
    throw new Error(`Pluma ${res.status} on ${pathAndQuery}: ${e?.code} — ${e?.message} ${e?.fix ?? ""}`)
  }
  return body
}

// All published entries of a type, with rich text as markdown and references resolved.
export async function getAll(contentType) {
  const items = [], assets = new Map()
  for (let skip = 0, limit = 1000; ; skip += limit) {
    const page = await get(`/entries?content_type=${contentType}&rich_text=markdown&include=1&limit=${limit}&skip=${skip}`)
    items.push(...page.items)
    for (const a of page.includes?.Asset ?? []) assets.set(a.sys.id, a)
    if (skip + limit >= page.total) break
  }
  return { items, assets }
}
```

## 2. One loader per collection

```ts
// src/content.config.ts
import { defineCollection, z } from "astro:content"
import { glob } from "astro/loaders"
import { PLUMA_ENABLED, getAll } from "./lib/pluma.mjs"

const plumaLoader = (contentType: string) => ({
  name: `pluma-${contentType}`,
  async load({ store, renderMarkdown }) {
    const { items } = await getAll(contentType)
    store.clear()
    for (const item of items) {
      const { slug, body, ...data } = item.fields
      store.set({ id: slug, data, body, rendered: await renderMarkdown(body ?? "") })
    }
  },
})

const blog = defineCollection({
  loader: PLUMA_ENABLED ? plumaLoader("blog_post") : glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({ title: z.string(), publishedAt: z.coerce.date(), /* … your usual schema … */ }),
})

export const collections = { blog }
```

With the same schema, the pages that use `getCollection("blog")` and `render()` don't change.

## 3. Images

The API returns `{ sys: { type: "Link", link_type: "Asset", id } }` in file fields and the URL in `includes.Asset`. Two options:

- **Use the Pluma URL** directly in `<img src>`. With Astro's `<Image>` (or Next's `next/image`), allow Pluma's file domains, or the build stops with `RemoteImageNotAllowed`:

  ```js
  // astro.config.mjs
  export default defineConfig({ image: { domains: ["pluma.so", "pluma.fly.dev"] } })
  ```

  `pluma.so` is where file URLs point now; `pluma.fly.dev` (the old one) still serves them, for URLs saved before October 1, 2026. In Next.js: `images.remotePatterns: [{ hostname: "pluma.so" }, { hostname: "pluma.fly.dev" }]`.
- **Download them before the build** to the usual paths (`public/images/…` or an ignored folder that gets copied to `dist/`). That way the HTML stays the same as with files. This is what the pilot did.

## 4. Variables on Netlify

`PLUMA_URL`, `PLUMA_SPACE` and `PLUMA_DELIVERY_KEY`. See [Deploy on Netlify](../guides/deploy-netlify.md).

## Gotchas the pilot found

- **Tailwind scans the `.md` files.** If you ever delete the files from the repo, classes that only appeared there (for example in a markdown table) disappear from the CSS. Before deleting them, add them to `safelist` in `tailwind.config.mjs`.
- **Empty text:** Pluma doesn't return empty fields. Set defaults (`?? ""`) when reading.
- **HTML inside markdown** is stored as text in `rich_text`. If your content uses it, store the markdown in a `text` field and render it with your pipeline.

---

<!-- docs/guides/create-a-site.md -->

# Step 1 · Create the site

Each site in Pluma is a **space**: it has its own content, team, keys and deploys. A person does this step, because creating the site sets who the owner is.

## Create the account

1. Go to `/signup`.
2. Enter your name, your email and a password of at least 10 characters.
3. Optional: the name of your organization (who pays). If you leave it empty, your name is used.

Once the account is created you are signed in, and Pluma takes you to create your first site.

## Create the site

1. On `/spaces/new`, enter the site **name**. Example: "Acton Estero".
2. Pluma suggests the **slug** from the name (`acton-estero`). You can change it.
3. Save.

The slug is the part of the site that shows up in API URLs and in your site's docs. Rules:

- Only lowercase letters, numbers and hyphens. Between 3 and 40 characters.
- Unique across all of Pluma.
- Choose it well: changing it later breaks the URLs your site already uses.

## What happens when you create it

- You become the site **owner**.
- The `main` environment is created. That is where all content lives in v1.
- The **14-day trial** starts. No card required.
- Step 1 of the checklist is done, and the dashboard shows you step 2: [invite your agent](invite-your-agent.md).

## The site dashboard

At `/spaces/<slug>` you see:

- The name, the production domain and the status of the last deploy.
- The **5-step checklist**. Each step checks itself off when Pluma sees it happened; there is no "done" button.
- Three cards: **Content**, **Agents and keys**, and **Activity** (who did what, human or agent).

## Possible errors

| Message | What to do |
| --- | --- |
| "That slug is already taken" | Choose another one. Add the city or a number. |
| "The slug can only contain lowercase letters, numbers and hyphens" | Remove spaces, capital letters and accents. |
| "An account with that email already exists" | Sign in at `/session/new`, or reset your password. |

---

<!-- docs/mcp/index.md -->

# MCP

Pluma has a remote [MCP](https://modelcontextprotocol.io) server. Your agent (Claude Code, Claude, Cursor, ChatGPT) connects to it once and then manages your content with tools, without writing `curl`.

```
https://pluma.so/mcp
```

`POST /mcp`

- **Streamable HTTP** transport, stateless: every request stands on its own.
- The tools are a thin layer over the same logic as the [Management API](../api/management.md): same validations, same errors with `fix` and `doc_url`.

## Authentication

Two ways, same result: a token for **one site**, and the tools work on that site.

- **OAuth** (the usual way): the client opens a Pluma screen, you pick the site and approve. See [MCP OAuth](oauth.md).
- **Direct token**: your agent's token (`pluma_agt_…`, from [step 2](../guides/invite-your-agent.md)) or a `management` key, as an `Authorization: Bearer …` header. For agents without a browser.

## Connect it

**Claude Code** (OAuth: then run `/mcp` and pick "Authenticate"):

```sh
claude mcp add --transport http pluma https://pluma.so/mcp
```

With a direct token:

```sh
claude mcp add --transport http pluma https://pluma.so/mcp --header "Authorization: Bearer $PLUMA_KEY"
```

**Cursor** (`.cursor/mcp.json`) and other clients with a JSON config:

```json
{
  "mcpServers": {
    "pluma": {
      "url": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer pluma_agt_…" }
    }
  }
}
```

Then ask your agent, for example: "With Pluma, show me the draft events and publish the Open House."

## Errors

If a tool fails, the response is a result with `isError: true` and the same error JSON as the API:

```json
{ "error": { "code": "validation_failed", "message": "…", "fix": "…", "doc_url": "…", "details": { "fields": { "slug": ["is required to publish"] } } } }
```

A missing or bad token gives HTTP 401 with [`missing_key`](../errors/missing_key.md) or [`invalid_key`](../errors/invalid_key.md).

The list of tools is in [MCP tools](tools.md).

---

<!-- docs/reference/rich-text.md -->

# Rich text

`rich_text` fields are stored as **JSON blocks**, not HTML. That way the content doesn't depend on any framework: the API also delivers it converted to markdown and to HTML.

In the dashboard you write in markdown. Through the API (phase 3) you send JSON or markdown.

## Document

```json
{
  "type": "doc",
  "content": [
    { "type": "heading", "level": 2, "content": [ { "type": "text", "text": "Open House" } ] },
    { "type": "paragraph", "content": [
      { "type": "text", "text": "A " },
      { "type": "text", "text": "great", "marks": ["bold"] },
      { "type": "text", "text": " day. " },
      { "type": "link", "href": "https://acton.example", "content": [ { "type": "text", "text": "Sign up" } ] }
    ] }
  ]
}
```

## Blocks

| `type` | Fields | Markdown |
| --- | --- | --- |
| `paragraph` | `content`: inline | A paragraph |
| `heading` | `level` (1 to 6), `content`: inline | `## Title` |
| `list` | `ordered` (bool), `content`: items `{ "type": "item", "content": [blocks] }` | `- one` or `1. one` |
| `quote` | `content`: blocks | `> quote` |
| `code` | `text`, `language` (optional) | Block with three backticks |
| `hr` | — | `---` |
| `image` | `asset` (ID of a Pluma asset) or `src` (external URL), and `alt` | `![alt](asset:ID)` or `![alt](https://…)` in its own paragraph |
| `table` | `content`: rows `{ "type": "row", "content": [ { "type": "cell", "content": [inline] } ] }`. The first row is the header | GitHub table: `\| A \| B \|` and the line `\| --- \| --- \|` |

## Inline

| `type` | Fields | Markdown |
| --- | --- | --- |
| `text` | `text`, `marks` (optional): `bold`, `italic`, `code`, `strike` | `**bold**`, `*italic*`, `` `code` ``, `~~strikethrough~~` |
| `link` | `href`, or `entry` (ID of another entry, see [Links to entries](#links-to-entries)); `content`: inline | `[text](url)` or `[text](entry:ID)` |
| `break` | — | Two spaces at the end of the line |

## Links to entries

To link to another entry of your site, write `[text](entry:12)`: it's stored as `{ "type": "link", "entry": "12", … }`, a link to **that entry**, not to its address today.

- **Reading** with `rich_text=markdown` or `html`, it comes out as the entry's path on your site, from its type's [page URL](../guides/content-model.md#page-url): `[seeded rye](/menu/rye-seeded)`. Change the slug and every link follows it, without editing the posts.
- If the type has no page URL, or the entry isn't published (with a delivery key), the link stays `entry:12` in markdown and becomes `#` in HTML.
- **Publishing** checks every entry link: the target has to exist and be published, or you get [`validation_failed`](../errors/validation_failed.md) with `links to entry:12, which isn't published yet`. Publish the target first (in a [batch](../api/management.md#batch), put its `publish_entry` earlier).

## Rules

- Markdown → blocks → markdown gives the same document (lossless round trip).
- When **reading** with `rich_text=markdown` or `rich_text=html`, Pluma images come out with their real URL, ready for your site. With `rich_text=json` they stay as `"asset": "ID"` and the URL is in `includes` (with `include=1`).
- HTML comes out escaped. Links that don't start with `http`, `https`, `mailto`, `/` or `#` are replaced with `#`: no `javascript:`.
- Raw HTML inside the markdown (`<u>`, `<div>`) is stored as **text**, not interpreted. If your content needs HTML, store it in a `text` field with the markdown as is and render it on your site.
- Table column alignment (`:---:`) is not stored.

---

<!-- docs/start/what-is-pluma.md -->

# What Pluma is

Pluma is a headless CMS: it stores your site's content (posts, events, pages, settings) and serves it by API to any frontend. Astro, Next, Nuxt, Eleventy, a mobile app: it doesn't matter.

The difference is **who it's made for**. In Pluma the main user is the site owner's AI agent. Anything a person does in the dashboard, an agent can do by API or by MCP, reading the same docs.

## How you use it

1. You create your site in the dashboard.
2. You give your agent an invite link.
3. The agent reads [`/llms.txt`](/llms.txt), looks at your current site, proposes how to model the content and imports it as drafts.
4. You review and publish. Your site rebuilds by itself.

## How it's different

| | Contentful, Sanity | Strapi, Payload, Directus | Pluma |
| --- | --- | --- | --- |
| Made for | Content teams | Developers | AI agents and the site owner |
| How a new site starts | A person models it in the UI | Code and configuration | An agent models it from a link |
| Docs | For people | For people | Docs first, with an `llms.txt` per site |
| Errors | Code and message | Code and message | Message + how to fix it + link to the docs |
| Hosting | SaaS | You run it yourself | SaaS, 49 USD per site per month |

## For agents

If you are an agent: everything you need is in [`/llms.txt`](/llms.txt). Pages marked "pending" describe something that doesn't exist yet; don't use them as if it did. Everything you create starts as a draft, and publishing needs a separate permission.

---

<!-- docs/api/delivery.md -->

# Delivery API

Reading content. With a `delivery` key it returns what is **published**; with a `preview` key, the latest version (see [Preview API](preview.md)). General conventions in [API](index.md).

Every example uses `acton-estero` as the site, `$PLUMA_KEY` as the key, and the `event` type created in the [Management API](management.md). The `curl`s on this page run in CI, in order, against a test database.

## Site

`GET /api/v1/spaces/:space_id`

```sh
curl https://pluma.so/api/v1/spaces/acton-estero -H "Authorization: Bearer $PLUMA_KEY"
```

```json
{
  "sys": { "id": "acton-estero", "type": "Space" },
  "name": "Acton Estero",
  "default_locale": "es", "prefix_default_locale": false,
  "locales": [ { "code": "es", "name": "Español", "default": true, "fallback_code": null } ],
  "content_types": [ { "api_id": "event", "name": "Event" } ]
}
```

## Content types

`GET /api/v1/spaces/:space_id/content_types`

`GET /api/v1/spaces/:space_id/content_types/:id`

```sh
curl https://pluma.so/api/v1/spaces/acton-estero/content_types/event -H "Authorization: Bearer $PLUMA_KEY"
```

```json
{
  "sys": { "id": "event", "type": "ContentType" },
  "name": "Event",
  "description": "Calendar events",
  "singleton": false,
  "display_field": "title",
  "fields": [
    { "api_id": "title", "name": "Title", "type": "symbol", "required": true, "localized": false },
    { "api_id": "slug", "name": "Slug", "type": "slug", "required": true, "localized": false },
    { "api_id": "host", "name": "Host", "type": "reference", "required": false, "localized": false, "link_content_types": ["person"] }
  ]
}
```

Hidden fields don't show up.

## Entries

`GET /api/v1/spaces/:space_id/entries`

`GET /api/v1/spaces/:space_id/entries/:id`

```sh
curl "https://pluma.so/api/v1/spaces/acton-estero/entries?content_type=event&fields.slug=open-house&rich_text=html" \
  -H "Authorization: Bearer $PLUMA_KEY"
```

```json
{
  "sys": { "type": "Array" }, "total": 1, "skip": 0, "limit": 100,
  "items": [
    {
      "sys": { "id": "12", "type": "Entry", "content_type": "event", "version": 3, "published_version": 3,
               "status": "published", "created_at": "2026-09-28T12:00:00Z", "updated_at": "2026-09-28T12:05:00Z",
               "published_at": "2026-09-28T12:05:00Z", "locale": "es" },
      "fields": {
        "title": "Open House",
        "slug": "open-house",
        "body": "<h2>Come join us</h2>\n<p>A <strong>great</strong> day.</p>",
        "cover": { "sys": { "type": "Link", "link_type": "Asset", "id": "4" } },
        "host": { "sys": { "type": "Link", "link_type": "Entry", "id": "7" } }
      }
    }
  ]
}
```

With your site's URLs and the type's `url_path` set, `sys` also carries `url` (where the entry lives on the live site, once published) and, with a key that reads drafts, `preview_url` (its page on the staging site). See [Staging and previews](../guides/staging.md).

### Parameters

| Parameter | What it does | Example |
| --- | --- | --- |
| `content_type` | Only entries of that type | `content_type=event` |
| `fields.<api_id>` | Equal to that value (text, number, yes/no, slug) | `fields.slug=open-house` |
| `order` | Order: `sys.created_at`, `sys.updated_at`, `sys.published_at` or `fields.<api_id>`; with a leading `-`, descending | `order=-sys.published_at` |
| `limit`, `skip` | Pagination | `limit=10&skip=20` |
| `locale` | Language, or `*` for all. Missing values fall back; filters, sorting and `sys.url` use it too ([Languages](../guides/languages.md)) | `locale=en` |
| `rich_text` | How rich text comes back: `json` (default), `markdown` or `html` | `rich_text=html` |
| `include` | Include the referenced entries and files, up to 2 levels | `include=1` |

With `include=1` the response adds:

```json
"includes": {
  "Entry": [ { "sys": { "id": "7", "type": "Entry", … }, "fields": { "name": "Rosa Caal" } } ],
  "Asset": [ { "sys": { "id": "4", "type": "Asset" }, "fields": { "title": "Playground", "alt": "Recess in the playground", "file": { … } } } ]
}
```

References stay as a `Link` in `fields`; you resolve them by looking up their `id` in `includes`. That way an entry that shows up twice travels only once.

## Files

`GET /api/v1/spaces/:space_id/assets`

`GET /api/v1/spaces/:space_id/assets/:id`

```json
{
  "sys": { "id": "4", "type": "Asset", "created_at": "…", "updated_at": "…" },
  "fields": {
    "title": "Playground", "alt": "Recess in the playground", "description": null,
    "file": { "url": "https://pluma.so/files/acton-estero/4-k3x9q2m7ab/playground.jpg", "filename": "playground.jpg",
              "content_type": "image/jpeg", "size": 61826, "width": 1600, "height": 1000 }
  }
}
```

With a `delivery` key, only the files used by some published entry show up.

## Possible errors

[`invalid_key`](../errors/invalid_key.md), [`missing_key`](../errors/missing_key.md), [`wrong_space`](../errors/wrong_space.md), [`not_found`](../errors/not_found.md), [`invalid_parameter`](../errors/invalid_parameter.md).

---

<!-- docs/guides/invite-your-agent.md -->

# Step 2 · Invite your agent

You don't set up anything technical. You create a link, give it to your agent (Claude, ChatGPT, Claude Code, Cursor), and it does the rest: it reads your site, proposes how to model the content, imports it and connects your site to Pluma.

## For you (the owner)

1. In your site, go to **Assistants → Invite agent**. Give it a name: "María's Claude".
2. Pluma shows you text ready to copy:

   > Connect my site to Pluma. Read the instructions at https://pluma.so/join/… and follow them.

3. Paste it to your agent. The link works **only once** and **expires in 24 hours**.
4. When your agent redeems it, it shows up in **Agents** with its name. Everything it does is recorded in the activity with the "agent" tag.
5. You can **revoke** it in one click. It stops working right away.

Step 2 of the checklist checks itself off when your agent has redeemed the link **and** created at least one content type and one entry. Until then, your site's home shows those three checks one by one, so a connected agent that hasn't modeled anything yet is easy to spot.

### What your agent can do

| Can | Cannot |
| --- | --- |
| Read all your content, published and drafts | Delete the site |
| Create and edit content types, entries and files | Invite or remove people |
| **Publish** | Create `management` keys or invite other agents |
| Create `delivery` and `preview` keys for your site | Give itself more permissions |

Everything it creates starts as a draft. It can publish, but the guide asks it to show you the plan first, and to ask you before publishing a change to something you didn't name or that shows up on more than one page.

## For your agent

The link returns markdown with everything it needs. Summary of the steps it asks for:

1. **Redeem the link** to get its token (shown once).
2. **Read the current site**: where the content lives today (config, collections, JSON, markdown with frontmatter). See [Migrate from files](migrate-from-files.md).
3. **Propose the model** (types and fields) and **show it to you before creating anything**.
4. Create the types with `dry_run=true` first, then for real.
5. **Import** entries and images.
6. **Create a `delivery` key** for the site build.
7. Change the site code to read from Pluma, on a branch with a PR.
8. Leave you a report: what it created, what it imported and what it couldn't map.

The endpoint reference is in [Agents](../api/agents.md) and [Management API](../api/management.md).

---

<!-- docs/mcp/tools.md -->

# MCP tools

They all work on the token's site. Arguments and responses have the same shape as the [API](../api/index.md): **everything the API does, the MCP does**, and the last column says which route each tool maps to. A test fails if a route shows up without a tool.

Each tool needs a permission. The MCP **only shows the tools the token can use**; see [What the agent can do](#what-the-agent-can-do).

| Tool | What it does | Permission | In the API |
| --- | --- | --- | --- |
| `describe_space` | The site: locales and each content type with its fields, plus its [agent guidance](../guides/content-model.md#agent-guidance) if it has any | `read_published` | `GET /api/v1`, `GET /api/v1/spaces/:space_id`, `GET /api/v1/spaces/:space_id/content_types`, `GET /api/v1/spaces/:space_id/content_types/:id` |
| `update_site` | Sets the live and staging URLs, so entries come with their `url` and `preview_url` ([Staging](../guides/staging.md)); the content's language (`default_locale`, which moves the values, it doesn't translate) and `prefix_default_locale` ([Languages](../guides/languages.md)) | `manage_webhooks` | `PATCH /api/v1/spaces/:space_id` |
| `add_locale` | Adds a language for translations: `code`, `name`, `fallback_code` | `manage_model` | `POST /api/v1/spaces/:space_id/locales` |
| `update_locale` | Renames a language or changes its fallback | `manage_model` | `PATCH /api/v1/spaces/:space_id/locales/:code` |
| `remove_locale` | Removes a language; with values in it, only with `delete_values: true`, which deletes them | `manage_model` | `DELETE /api/v1/spaces/:space_id/locales/:code` |
| `search_docs` | Searches the Pluma docs and returns the matching pages | — | `GET /api/v1/spaces/:space_id/llms.txt` |
| `export_space` | The whole site in one JSON: types, entries (latest and published) and files | `write` | `GET /api/v1/spaces/:space_id/export` |
| `create_content_type` | Creates a type with its fields. With `dry_run: true` it saves nothing | `manage_model` | `POST /api/v1/spaces/:space_id/content_types` |
| `update_content_type` | Changes name, description or title field. The `api_id` doesn't change | `manage_model` | `PATCH /api/v1/spaces/:space_id/content_types/:id` |
| `add_field` | Adds a field to an existing type | `manage_model` | `POST /api/v1/spaces/:space_id/content_types/:content_type_id/fields` |
| `update_field` | Edits a field; `hidden: true` hides it without deleting data | `manage_model` | `PATCH /api/v1/spaces/:space_id/content_types/:content_type_id/fields/:id` |
| `list_entries` | Entries with filters: `content_type`, `fields` (equality), `order`, `limit`, `skip`, `rich_text`, `locale` | `read_published` | `GET /api/v1/spaces/:space_id/entries` |
| `get_entry` | One entry by `id`, in a `locale` if you want (`*` for all) | `read_published` | `GET /api/v1/spaces/:space_id/entries/:id` |
| `list_versions` | An entry's versions: who, when, which one is published | `read_drafts` | `GET /api/v1/spaces/:space_id/entries/:id/versions` |
| `create_entry` | Creates a draft entry: `content_type`, `fields`, and `locale` to write another language | `write` | `POST /api/v1/spaces/:space_id/entries` |
| `update_entry` | Changes only the fields you send. `version` keeps you from overwriting someone else's changes; `locale` writes another language | `write` | `PATCH /api/v1/spaces/:space_id/entries/:id` |
| `publish_entry` | Publishes the latest version (checks required fields, slugs, references, alt) | `publish` | `POST /api/v1/spaces/:space_id/entries/:id/publish` |
| `unpublish_entry` | Takes it off the site; the entry goes back to draft with its versions | `publish` | — |
| `archive_entry` | Archives: it leaves the site and stays saved | `publish` | `POST /api/v1/spaces/:space_id/entries/:id/archive` |
| `unarchive_entry` | Takes it out of the archive; it comes back as a draft | `publish` | `POST /api/v1/spaces/:space_id/entries/:id/unarchive` |
| `translate_entry` | Fills a language's missing localized fields from the default one with AI, as a draft version; `overwrite: true` redoes them all ([Translate with AI](../guides/languages.md#translate-with-ai)) | `write` | `POST /api/v1/spaces/:space_id/entries/:id/translate` |
| `batch` | Several operations in one call, all or nothing (up to 100). See [Batch](../api/management.md#batch) | `write` and `publish` depending on the operation | `POST /api/v1/spaces/:space_id/batch` |
| `list_assets` | The site's files, with URL, type, size and alt | `read_published` | `GET /api/v1/spaces/:space_id/assets` |
| `get_asset` | One file by `id` | `read_published` | `GET /api/v1/spaces/:space_id/assets/:id` |
| `upload_asset` | Uploads a file in base64 (`data`, `filename`) or from a `url`, with `title` and `alt` | `write` | `POST /api/v1/spaces/:space_id/assets` |
| `update_asset` | Changes a file's title, alt or description | `write` | `PATCH /api/v1/spaces/:space_id/assets/:id` |
| `list_api_keys` | The active keys, without the token | `manage_keys` | `GET /api/v1/spaces/:space_id/api_keys` |
| `create_api_key` | Creates a `delivery` or `preview` key and returns its token once | `manage_keys` | `POST /api/v1/spaces/:space_id/api_keys` |
| `get_activity` | The site's latest changes: who, what and when | `read_drafts` | — |
| `list_webhooks` | The deploy hooks, with their last delivery | `manage_webhooks` | `GET /api/v1/spaces/:space_id/webhooks` |
| `create_webhook` | Creates a deploy hook (`netlify`, `vercel` or `generic`) and returns its secret once | `manage_webhooks` | `POST /api/v1/spaces/:space_id/webhooks` |
| `test_webhook` | Sends a test hook and returns what the destination answered | `manage_webhooks` | `POST /api/v1/spaces/:space_id/webhooks/:id/test` |
| `delete_webhook` | Deletes a deploy hook | `manage_webhooks` | `DELETE /api/v1/spaces/:space_id/webhooks/:id` |
| `get_analytics` | The site's [visitors](../guides/analytics.md) for the last N days: from AI assistants, sources, pages, places, UTM campaigns, devices, and AI crawlers | `read_drafts` | `GET /api/v1/spaces/:space_id/analytics` |
| `get_answers` | What [ChatGPT answers](../guides/ai-answers.md) to the site's questions: mentioned, cited, position, competitors in the answers, wrong facts | `read_drafts` | `GET /api/v1/spaces/:space_id/answers` |
| `update_questions` | Replaces the questions Pluma asks ChatGPT every week (1 to 30) and, optionally, the country and city they're asked from | `manage_model` | `PATCH /api/v1/spaces/:space_id/questions` |
| `get_readiness` | The last [AI readiness check](../guides/ai-visibility.md) of the live site, with what to fix | `read_drafts` | `GET /api/v1/spaces/:space_id/readiness` |
| `check_readiness` | Reads the live site like an AI crawler and runs the checks again (once a minute at most) | `read_drafts` | `POST /api/v1/spaces/:space_id/readiness` |
| `get_search_queries` | What people [searched on Google and Bing](../guides/search-console.md): top queries and pages with clicks, impressions, CTR and position. Empty until an owner connects them | `read_drafts` | `GET /api/v1/spaces/:space_id/search_queries` |

## What the agent can do

The same as the person who connected it, based on their role on the site, and never more. The MCP **only shows it the tools it can use**; a tool it can't see gives `Tool not found`. In a [batch](../api/management.md#batch), an operation the role doesn't allow gives [`key_cannot`](../errors/key_cannot.md) and nothing is saved.

| Person's role | Agent's permissions | What it does |
| --- | --- | --- |
| Owner, Admin | all | Everything: content, publishing, model, keys, deploy hooks |
| Editor | `read_published`, `read_drafts`, `write`, `publish` | Creates, edits and publishes content and files. Doesn't touch the model, keys or deploys |
| Author | `read_published`, `read_drafts`, `write` | Creates and edits content and files, as drafts. Doesn't publish |
| Viewer | `read_published`, `read_drafts` | Read only |

- It's the role **today**: if it changes, the agent changes right away, without reconnecting.
- If an owner [limited the person to some content types or entries](../guides/team.md#limit-what-someone-can-edit), the agent gets the same limit: it only changes those, and elsewhere it only sees what's published.
- Keys (`delivery`, `preview`, `management`) have no person: their permissions are those of their kind (see [API](../api/index.md)).

## What clients confirm

Each tool tells the client what it does to the world, with the standard MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`). ChatGPT and Claude use them to decide when to ask you before running a tool: reading goes straight through, publishing or deleting asks first.

| Kind | What it means | Key annotation | Tools |
| --- | --- | --- | --- |
| read | Only reads. Clients run it without asking | `readOnlyHint: true` | `describe_space`, `search_docs`, `export_space`, `list_entries`, `get_entry`, `list_versions`, `list_assets`, `get_asset`, `list_api_keys`, `get_activity`, `list_webhooks`, `get_readiness`, `get_analytics`, `get_answers`, `get_search_queries` |
| change | Changes drafts, files or settings inside Pluma. Nothing reaches the live site | `readOnlyHint: false` | `create_content_type`, `update_content_type`, `add_field`, `create_entry`, `update_entry`, `archive_entry`, `unarchive_entry`, `upload_asset`, `update_asset`, `create_api_key`, `create_webhook`, `update_site`, `update_questions`, `add_locale`, `update_locale`, `translate_entry` |
| public | Changes what the live site shows | `openWorldHint: true` | `publish_entry`, `unpublish_entry`, `batch` |
| destructive | Deletes something, or can lose data | `destructiveHint: true` | `update_field`, `delete_webhook`, `remove_locale` |
| external | Calls a URL outside Pluma | `openWorldHint: true` | `test_webhook`, `check_readiness` |

## Examples

**Create and publish**, in a conversation:

> You: "Create an event Science fair on November 5 at 2:00 PM, and publish it."
>
> The agent calls `describe_space` to see the fields of `event`, then `create_entry` with `{ "content_type": "event", "fields": { "title": "Science fair", "slug": "science-fair", "startsAt": "2026-11-05T14:00:00-06:00" } }`, and `publish_entry` with the `id` it got back.

**Read with filters:**

```json
{ "name": "list_entries", "arguments": { "content_type": "event", "order": "fields.startsAt", "limit": 5 } }
```

**Upload an image:**

```json
{ "name": "upload_asset", "arguments": { "filename": "playground.jpg", "data": "<base64>", "title": "Playground", "alt": "Recess in the playground" } }
```

Or with `"url": "https://…"` instead of `data` and `filename`. Same rules as the API: JPG, PNG, WebP, GIF, SVG or PDF up to 20 MB; MP4 or WebM up to 50 MB.

**Migrate lots of things at once:**

```json
{ "name": "batch", "arguments": { "operations": [
  { "op": "create_asset", "ref": "playground", "url": "https://…/playground.jpg", "alt": "Recess in the playground" },
  { "op": "create_entry", "ref": "post", "content_type": "post", "fields": { "title": "Hello", "cover": "$playground" } },
  { "op": "publish_entry", "id": "$post" }
] } }
```

If one operation fails, none are saved, and the error says which one in `details.operation`.

**Translate an entry** into a language the site has, then read it before publishing:

```json
{ "name": "add_locale", "arguments": { "code": "en" } }
{ "name": "translate_entry", "arguments": { "id": "12", "to": "en" } }
{ "name": "get_entry", "arguments": { "id": "12", "locale": "en" } }
```

---

<!-- docs/start/concepts.md -->

# Concepts

The names are Contentful's, on purpose: if you have used it, you already know what each thing is called.

| Concept | What it is | Example |
| --- | --- | --- |
| **Space** | A site. It has its content, its team, its keys and its deploys | `acton-estero` |
| **Environment** | A copy of the content. In v1 only `main` exists | `main` |
| **Content type** | The shape of a piece of content: which fields it has. Defined without code | `event`, `blog_post` |
| **Field** | A field of a content type, with a type and validations | `title` (short text, required) |
| **Singleton** | A content type with a single entry. For the site's settings | `site_settings` |
| **Entry** | One concrete piece of content of a content type | The "Open House" event |
| **Asset** | A file: image, PDF | `open-house.jpg` |
| **Locale** | A language of the space, with a fallback language | `es`, `en` |
| **API key** | Access by token. Three kinds: `delivery` (reads what is published), `preview` (reads drafts), `management` (writes) | `pk_live_…` |
| **Agent** | An AI agent invited to the space, with its own permissions | The owner's Claude Code |

## Entry states

`draft` → `published` → `archived`. Everything an agent creates starts as `draft`. Publishing is a separate step, with its own permission.

Every change is kept in the history: who (person or agent), what and when. Any version can be restored.

---

<!-- docs/api/preview.md -->

# Preview API

It's the [Delivery API](delivery.md), with a `preview` key. Same routes, same parameters, same response shape. The difference:

| | `delivery` key | `preview` key |
| --- | --- | --- |
| Draft entries | Not shown | Shown |
| Entries with unpublished changes | The published version | The latest version |
| Archived entries | Not shown | Not shown |
| Files | Only those used by something published | All |
| Cache | `public`, 5 minutes on the CDN | `private, no-store` |

Each entry tells you what you're seeing in `sys`: `version` is the one you got, `published_version` the published one (or `null` if it was never published), and `status` is `draft`, `published` or `changed`.

Use it for your site's preview. **Don't use it in the production build:** you'd show drafts.

---

<!-- docs/guides/content-model.md -->

# Model your content

A **content type** is the shape of a piece of content: which fields it has. You define it in the dashboard, with no code. Usually your agent does it in [step 2](invite-your-agent.md); this guide explains what it does, so you can review it or do it by hand.

## Create a content type

In your site, go to **Model → New type**:

| Field | What it is | Example |
| --- | --- | --- |
| Name | How people see it | "Event" |
| API ID | How the API asks for it. Lowercase letters, numbers and underscores; starts with a letter | `event` |
| Description | Optional. Your agent reads it to know what the type is for | "Events on the school calendar" |
| Singleton | Whether it has **a single entry**. For site settings | `site_settings` |

The API ID can't be changed later: your site already uses it to fetch the content.

## Agent guidance

Each type can carry instructions for whoever adds content, a person or your agent. You edit them on the type's page, under **Agent guidance**:

- **When to use it and when not to.** One or two sentences. "Use Pricing for tuition and fees. Not for event lists." Up to 2000 characters.
- **What it looks like.** Up to 4 previews of the site, picked from your [files](files.md). They must be images.
- **Help per field.** One line: format, unit or example. "In USD, no symbol: 12500".

Your agent reads them before creating anything, in `describe_space` in the [MCP](../mcp/tools.md) and in your site's `llms.txt`. Over the API they come in `guidance`, `previews` and `fields[].help` on each content type. They help most when there are several similar types, like the sections of a page: without guidance, the agent guesses which one to use. See [Composable pages](composable-pages.md).

## Page URL

Tell Pluma where each entry of the type lives on your site: `/blog/{slug}`, `/events/{id}`. With it, every entry gets **View live** and **View on staging** links, and the API returns its `sys.url`. See [Staging and previews](staging.md).

## Add fields

Each field has a name, an API ID, a type, and three options:

- **Required:** an entry can't be published without this field.
- **Localized:** it has one value per locale. If not, the same value applies to all of them.
- **Title field:** the one shown in lists. One per type.

## Field types

| Type | API | What it stores | Example |
| --- | --- | --- | --- |
| Short text | `symbol` | Up to 256 characters | Title |
| Long text | `text` | Plain text with no limit | Summary |
| Rich text | `rich_text` | JSON blocks: paragraphs, headings, lists, images. The API also returns it as markdown and HTML | Post body |
| Integer | `integer` | 42 | Seats |
| Decimal number | `number` | 3.5 | Price |
| Yes / no | `boolean` | true or false | Featured |
| Date | `date` | Date only (`2026-08-14`), date and time with its time zone (`2026-10-12T09:00:00-06:00`) or without a zone (`2026-10-12T09:00:00`). Stored as is, not converted to UTC | Event date |
| JSON | `json` | Any JSON object | Extra data |
| Slug | `slug` | Lowercase letters, numbers and hyphens. Unique within the type | `open-house` |
| Tags | `tags` | List of short texts | `["families", "2026"]` |
| Reference | `reference` | Another entry. Can be limited to certain types (even types you haven't created yet) | The post author |
| References | `references` | Several entries | Related posts |
| File | `asset` | An image or document | Cover photo |
| Files | `assets` | Several | Gallery |
| List | `list` | Rows of simple item fields, in order. See [Lists](#lists) | Menu, opening hours, social links |

## Lists

For things that are rows of the same small shape: the site menu (label, url), opening hours (days, open, close), social links (network, url). Each row is edited as a row in the dashboard, with add, move up and remove, instead of raw JSON.

- A list has **1 to 10 item fields**. Each has an `api_id`, a `name` and a type: `symbol` (default), `text`, `integer`, `number`, `boolean` or `date`.
- Up to **100 rows**. Empty rows are dropped when saving. The order you see is the order you get.
- In the dashboard, when you add a field of type List, write the item fields separated by commas: `Label, URL` or `Days, Open, Close, Closed:boolean`.
- By API or MCP, define them with `item_fields` and send the value as a list of objects:

```json
{ "api_id": "navigation", "name": "Menu", "type": "list",
  "item_fields": [ { "api_id": "label", "type": "symbol" }, { "api_id": "url", "type": "symbol" } ] }
```

```json
{ "fields": { "navigation": [ { "label": "Menu", "url": "/menu" }, { "label": "Visit", "url": "/visit" } ] } }
```

A row with a key that isn't an item field is rejected, with the name of the field it expected. If each row needs its own page, a photo or references, use a content type of its own instead.

## Rules that protect your content

- **Deleting a field doesn't delete data.** The field is hidden and the data stays in each entry's previous versions.
- **Changing the type of a field that already has data** is rejected, with a message that explains how to migrate: create a new field, copy the data, and hide the old one.
- **An API ID is never reused** within the same type, even if the field is hidden.
- A type with entries can't be deleted until you archive them.

## Possible errors

| Message | What to do |
| --- | --- |
| "That API ID already exists in this site" | Choose another one, or use the type that already exists. |
| "The API ID must start with a letter and contain only lowercase letters, numbers and underscores" | `blog_post` works; `Blog-Post` doesn't. |
| "You can't change the type of a field that has data" | Create a new field and hide the old one. |

---

<!-- docs/mcp/oauth.md -->

# MCP OAuth

MCP clients (ChatGPT, Claude, Claude Code, Cursor, VS Code, Codex and others) connect with **OAuth 2.1**: they open a Pluma screen, you pick the site and approve, and the client is connected. No copying tokens. This page is the technical reference; the guide for people is [Step 3 · Connect the MCP](../guides/connect-mcp.md).

It implements [MCP authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization): protected resource metadata (RFC 9728), authorization server metadata (RFC 8414), dynamic client registration (RFC 7591), and authorization code with required **PKCE S256**.

## Discovery

A request to `/mcp` without a token responds `401` with:

```
WWW-Authenticate: Bearer resource_metadata="https://pluma.so/.well-known/oauth-protected-resource/mcp"
```

`GET /.well-known/oauth-protected-resource`

`GET /.well-known/oauth-protected-resource/mcp`

```json
{ "resource": "https://pluma.so/mcp", "authorization_servers": ["https://pluma.so"], "bearer_methods_supported": ["header"] }
```

`GET /.well-known/oauth-authorization-server`

```json
{
  "issuer": "https://pluma.so",
  "authorization_endpoint": "https://pluma.so/oauth/authorize",
  "token_endpoint": "https://pluma.so/oauth/token",
  "registration_endpoint": "https://pluma.so/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}
```

## Client registration

`POST /oauth/register`

```json
{ "client_name": "Claude Code", "redirect_uris": ["http://localhost:53682/callback"] }
```

- Public clients: no `client_secret` (`token_endpoint_auth_method: none`). PKCE provides the security.
- Each `redirect_uri` must be `https://`, or `http://` to `localhost`/`127.0.0.1` (native apps).
- Loopback addresses (`http://127.0.0.1`, `http://localhost`) match on **any port**, as in RFC 8252: native clients pick a free port each time. Scheme, host and path still have to match. Everything else must match exactly.
- Without `client_name`, the client is called "MCP client".
- Responds `201` with the `client_id`.

## Authorization

`GET /oauth/authorize`

Parameters: `response_type=code`, `client_id`, `redirect_uri` (must match a registered one), `code_challenge` and `code_challenge_method=S256`, `state`, and optionally `resource`. Pluma accepts `https://pluma.so/mcp` or the bare origin `https://pluma.so` (ChatGPT sends the origin); any other value is rejected.

Pluma asks you to sign in (if you haven't), shows you which client is asking for access and which `redirect_uri` it will return to, and lets you **pick the site**. Anyone on the site can connect a client: the agent can do the same as their role (see [What the agent can do](tools.md#what-the-agent-can-do)).

`POST /oauth/authorize`

This is the button on the screen. On approve, it returns to `redirect_uri?code=…&state=…`; on cancel, `?error=access_denied&state=…`. The code works **once** and for **10 minutes**.

## Token

`POST /oauth/token`

Form-encoded: `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier`.

```json
{ "access_token": "pluma_agt_…", "token_type": "Bearer", "scope": "read_published read_drafts write publish" }
```

- The token belongs to a new **agent** on the chosen site, named after the client. It shows up under **Agents** and you revoke it there.
- `scope` is the permissions given by the role of whoever approved (the example is from an editor). See [What the agent can do](tools.md#what-the-agent-can-do).
- It doesn't expire on its own: it lives until you revoke it. There is no `refresh_token`.
- Reusing a code revokes the agent that code created (protection against stolen codes).

Token errors use the OAuth format: `{"error": "invalid_grant", "error_description": "…"}`.

---

<!-- docs/start/first-5-minutes.md -->

# First 5 minutes

From zero to reading your first content by API. If you'd rather have your agent do it, jump to [Invite your agent](../guides/invite-your-agent.md): it does these steps by itself.

## 1. Create the site

Sign in to Pluma and create a site. Its **slug** (for example `acton-estero`) goes in every API URL. See [Create the site](../guides/create-a-site.md).

## 2. Add something and publish it

In **Model**, create an `event` type with a `title` field. In **Content**, create an event and click **Publish**. Drafts don't come out of the Delivery API.

## 3. Create a read key

In **Keys**, create a **delivery** key. The token is shown only once: copy it.

```sh
export PLUMA_KEY=pluma_dlv_…
```

## 4. Read your content

```sh
curl https://pluma.so/api/v1/spaces/acton-estero/entries?content_type=event -H "Authorization: Bearer $PLUMA_KEY"
```

```json
{ "sys": { "type": "Array" }, "total": 1, "skip": 0, "limit": 100,
  "items": [ { "sys": { "id": "1", "type": "Entry", "content_type": "event", "status": "published" }, "fields": { "title": "Open House" } } ] }
```

## 5. Take it to your site

- Astro: the [Astro recipe](../frameworks/astro.md).
- Anything else: [plain fetch](../frameworks/fetch.md), a `GET` from whatever language you use.
- To make the site rebuild by itself when you publish: [Deploy on Netlify](../guides/deploy-netlify.md).

If something fails, the error says what happened and how to fix it, with a link to its page in [Errors](../errors/index.md).

---

<!-- docs/api/management.md -->

# Management API

Writing: the model, the entries, the files. It needs a **`management`** key or an **agent token** (`pluma_agt_`, see [Agents](agents.md)); with a `delivery` or `preview` key you get [`key_cannot`](../errors/key_cannot.md). Conventions in [API](index.md).

Everything written by API **starts as a draft**. Publishing is a separate request. Every change goes into the site's activity with the key's name.

## Site settings

`PATCH /api/v1/spaces/:space_id`

```json
{ "production_url": "https://www.example.com", "preview_url": "https://staging.example.com" }
```

Where the live site and the [staging site](../guides/staging.md) live. With them and each type's `url_path`, entries come with `sys.url` and `sys.preview_url`. Needs `manage_webhooks`. An empty string clears a URL. Whatever you don't send doesn't change.

It also takes the site's languages settings ([Languages](../guides/languages.md)):

- **`default_locale`**: the language the content is written in, like `"es"`. Changing it **moves** every value stored under the old code to the new one (all entries, all versions) and renames the language; it doesn't translate. If an entry has values in both, you get [`validation_failed`](../errors/validation_failed.md) with `details.fields.default_locale` naming the entries, and nothing changes.
- **`prefix_default_locale`**: `true` puts the default language in URLs built with `{locale}` too (`/es/menu`); `false` (the default) leaves it out (`/menu`).

## Languages

The site's languages are in `GET /api/v1/spaces/:space_id` (`locales`). Changing them needs `manage_model`.

`POST /api/v1/spaces/:space_id/locales`

```json
{ "code": "en", "name": "English", "fallback_code": "es" }
```

- `code`: two letters, or two plus a region (`en-US`). `name` is optional (the language's own name by default).
- `fallback_code`: what to show when a value is missing in this language. Without it, the default language; `null` for none.
- It answers `201` with `{ "code": "en", "name": "English", "default": false, "fallback_code": "es" }`.

`PATCH /api/v1/spaces/:space_id/locales/:code` changes `name` or `fallback_code`.

`DELETE /api/v1/spaces/:space_id/locales/:code` removes a language. **Its values are deleted** from every entry and version, published ones too, so if it has any, it refuses with [`validation_failed`](../errors/validation_failed.md) (`details.fields.delete_values` says how many entries) unless you add `?delete_values=true`. The default language can't be removed. It answers with `entries_changed` and the languages left.


**Reading** (list types, get one entry, filter by field) uses the same routes as the [Delivery API](delivery.md): `GET /api/v1/spaces/:space_id/content_types`, `GET /api/v1/spaces/:space_id/entries/:id`, `GET /api/v1/spaces/:space_id/entries` with filters like `?content_type=menu_item&fields.slug=morning-bun`. With a management key or an agent token they return the latest version, drafts included.

## Model

### Create a content type

`POST /api/v1/spaces/:space_id/content_types`

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/content_types?dry_run=true \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Event", "api_id": "event", "description": "Calendar events",
    "fields": [
      { "api_id": "title", "name": "Title", "type": "symbol", "required": true },
      { "api_id": "slug", "name": "Slug", "type": "slug", "required": true },
      { "api_id": "startsAt", "name": "Starts", "type": "date" },
      { "api_id": "body", "name": "Description", "type": "rich_text" }
    ]
  }'
```

- With **`dry_run=true`** nothing is saved: the response says what would be created, or the errors. Always use it first.
- If it looks right, send the same thing **without** `dry_run`. It answers `201` with the content type (same shape as in the [Delivery API](delivery.md)):

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/content_types \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Event", "api_id": "event", "description": "Calendar events",
    "fields": [
      { "api_id": "title", "name": "Title", "type": "symbol", "required": true },
      { "api_id": "slug", "name": "Slug", "type": "slug", "required": true },
      { "api_id": "startsAt", "name": "Starts", "type": "date" },
      { "api_id": "body", "name": "Description", "type": "rich_text" }
    ]
  }'
```

- The first `symbol` or `slug` field becomes the title field, unless you send `display_field`.
- Optional on create too: `guidance`, `previews` and `url_path` (where its entries live on your site, like `/blog/{slug}`).
- **Agent guidance** (optional): `guidance` (when to use the type), `previews` (ids of up to 4 images that show what it looks like) and `help` on each field (one line: format or example). See [Agent guidance](../guides/content-model.md#agent-guidance).
- For the site's settings (name, phone, menu), send `"singleton": true`: the type has a single entry.
- Field types: the ones in [Model your content](../guides/content-model.md). References accept `link_content_types`.

### Edit a content type

`PATCH /api/v1/spaces/:space_id/content_types/:id`

Accepts `name`, `description`, `display_field`, `guidance`, `previews` (`[]` clears them) and `url_path` (where its entries live on your site, like `/blog/{slug}`; see [Staging](../guides/staging.md)). The `api_id` can't be changed.

Adding or editing a field and editing a content type also take **`?dry_run=true`**: nothing is saved and the answer is `{"dry_run": true, "would_update": …}` with the content type as it would be, or the same errors as the real call. A field's `api_id` is never reused, so try new fields with `dry_run` first.

### Add a field

`POST /api/v1/spaces/:space_id/content_types/:content_type_id/fields`

```json
{ "api_id": "seats", "name": "Seats", "type": "integer" }
```

### Edit or hide a field

`PATCH /api/v1/spaces/:space_id/content_types/:content_type_id/fields/:id`

Accepts `name`, `required`, `localized`, `type`, `link_content_types`, `help` (one line, up to 300 characters) and `hidden` (`true` hides it, `false` shows it). Whatever you don't send doesn't change. Changing `type` on a field that has data gives [`validation_failed`](../errors/validation_failed.md).

## Entries

### Create

`POST /api/v1/spaces/:space_id/entries`

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/entries \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "content_type": "event",
    "fields": {
      "title": "Open House",
      "slug": "open-house",
      "startsAt": "2026-10-12T09:00:00-06:00",
      "body": "## Come join us\n\nA **great** day."
    }
  }'
```

- It answers `201` with the entry as a draft (Delivery API shape, `sys.status: "draft"`).
- **Rich text:** send markdown (a string) or the [block document](../reference/rich-text.md) (an object).
- **References:** the entry's `id` (`"7"`). **Files:** the asset's `id`.
- **Dates:** stored as you send them. `"2026-08-14"` stays a date only; `"2026-10-12T09:00:00-06:00"` keeps its time zone. They are not converted to UTC.
- Saving validates the types; required fields are validated on publish.

### Edit

`PATCH /api/v1/spaces/:space_id/entries/:id`

```json
{ "version": 3, "fields": { "title": "Open House 2026" } }
```

- Only the fields you send change. To empty one, send it as `null`.
- Every edit is a new version.
- **`version`** (optional, recommended): the version you read. If someone saved another one in between, you get [`version_conflict`](../errors/version_conflict.md) and nothing is overwritten.
- **Another language:** add `?locale=en` and the fields you send are written in English (only localized fields). With `?locale=*`, each field is an object by language, `{ "title": { "es": "Menú del día", "en": "Daily menu" } }`, and errors for a language other than the default come as `title.en`. Creating takes the same `locale`. See [Languages](../guides/languages.md#by-api).

### Translate with AI

`POST /api/v1/spaces/:space_id/entries/:id/translate`

```json
{ "to": "en" }
```

Fills the English localized fields that are still empty from the default language, with AI, and saves them as a **new draft version**. It never publishes. Text, rich text (same structure), slugs (translated and unique), tags and the text in lists are translated; references, files, numbers, dates and yes/no stay. `"overwrite": true` translates every field again. Needs `write`.

It answers with the entry in that language plus `translation`: `{ "from": "es", "to": "en", "fields": ["title", "slug", "body"], "skipped": [] }`. `skipped` lists rich text fields whose translation came back with a different structure; they're left as they were. If the AI provider is down: [`translation_unavailable`](../errors/translation_unavailable.md), and nothing is saved.

### Publish, archive, unarchive

`POST /api/v1/spaces/:space_id/entries/:id/publish`

`POST /api/v1/spaces/:space_id/entries/:id/archive`

`POST /api/v1/spaces/:space_id/entries/:id/unarchive`

Publishing validates the whole entry (required fields, unique slugs, references, files with alt text). If it fails, you get [`validation_failed`](../errors/validation_failed.md) with the details per field.

### Versions

`GET /api/v1/spaces/:space_id/entries/:id/versions`

```json
{ "sys": { "type": "Array" }, "total": 3, "items": [
  { "version": 3, "author": { "type": "ApiKey", "name": "Rosa's agent" }, "created_at": "…", "published": false, "fields": { … } }
] }
```

## Files

### Upload

`POST /api/v1/spaces/:space_id/assets`

As multipart, with the file:

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/assets \
  -H "Authorization: Bearer $PLUMA_KEY" \
  -F "file=@playground.jpg" -F "title=Playground" -F "alt=Recess in the playground"
```

Or as JSON, with the file's **URL**: Pluma downloads it. Useful for migrating from another CMS without downloading anything to your machine.

```json
{ "url": "https://images.ctfassets.net/…/playground.jpg", "alt": "Recess in the playground" }
```

- The URL must be `https` and on the public internet. Up to 3 redirects are followed, and each one is checked again.
- `filename` (optional) changes the name; otherwise the one from the URL is used. The title comes from the name if you don't send `title`.
- Types and sizes: JPG, PNG, WebP, GIF, SVG and PDF up to 20 MB; MP4 and WebM up to 50 MB. An SVG can't contain scripts or load things from outside. See [Images and files](../guides/files.md).

### Edit or replace a file

`PATCH /api/v1/spaces/:space_id/assets/:id`

Accepts `title`, `alt` and `description`. To **replace the file itself**, also send `file` (multipart) or `url` (JSON, like when uploading). The id stays the same, so every entry that uses it shows the new file without being edited. Same types and sizes as an upload.

```bash
# 4 is the id of the file you're replacing
curl -X PATCH https://pluma.so/api/v1/spaces/acton-estero/assets/4 \
  -H "Authorization: Bearer $PLUMA_KEY" -F "file=@patio-2027.jpg"
```

If something published uses the file, the [deploy hooks](webhooks.md) get `asset.replaced` so the site rebuilds with it.

## Batch

`POST /api/v1/spaces/:space_id/batch`

Several operations in a single request, **all or nothing**: if one fails, nothing is saved. To migrate a whole site in a few calls instead of hundreds.

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/batch \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{
    "operations": [
      { "op": "create_entry", "ref": "fair", "content_type": "event",
        "fields": { "title": "Science fair", "slug": "science-fair", "startsAt": "2026-11-05T14:00:00-06:00" } },
      { "op": "create_entry", "ref": "graduation", "content_type": "event",
        "fields": { "title": "Graduation", "slug": "graduation", "startsAt": "2026-11-28" } },
      { "op": "publish_entry", "id": "$fair" },
      { "op": "publish_entry", "id": "$graduation" }
    ]
  }'
```

It answers `200` with one result per operation, in the same order:

```json
{ "dry_run": false, "total": 4, "results": [
  { "op": "create_entry", "ref": "fair", "sys": { "id": "12", "type": "Entry", "content_type": "event", "version": 1, "status": "draft" } },
  …
  { "op": "publish_entry", "sys": { "id": "13", "type": "Entry", "content_type": "event", "version": 1, "status": "published" } }
] }
```

| `op` | Fields | Permission |
| --- | --- | --- |
| `create_entry` | `content_type`, `fields`, `locale` (optional) | `write` |
| `update_entry` | `id`, `fields`, `version` (optional), `locale` (optional) | `write` |
| `create_asset` | `url`, or `data` (base64) + `filename` for a local file; `title`, `alt`, `description` | `write` |
| `publish_entry` | `id` | `publish` |
| `archive_entry` | `id` | `publish` |
| `unarchive_entry` | `id` | `publish` |

- **`ref`:** give an operation a name and use it later as `"$name"`: in `id`, and in reference fields (`reference`, `references`) or file fields (`asset`, `assets`). That way you create an author and a post that references it in the same batch, without knowing the ids. They are not resolved inside rich text: use the real id there.
- **`locale`:** like `?locale=` on a single entry: `"en"` writes English, `"*"` takes each field as an object by language. See [Languages](../guides/languages.md#by-api).
- **Limits:** up to **100 operations** and **20 files** (`create_asset`) per batch. It counts as **one** request for the [per-token limit](index.md#limits).
- **`dry_run=true`:** runs everything, validates everything and saves nothing. Files are still downloaded, to validate them. The ids in a dry run are not reserved: the real run can get different ones, so always use `"$ref"`.
- **Links:** with the site's URLs and the type's `url_path` set, each entry result also carries `url` and `preview_url` (see [Staging](../guides/staging.md)), so you can hand the owner the link without reading the entry again.
- **If something fails**, the error is that operation's (`validation_failed`, `not_found`, `version_conflict`, `key_cannot`…) and `details.operation` says which one (starting at 0), with its `op` and its `ref`. Fix that one and send the whole batch again.
- Deploy hooks fire only if the batch was saved: a batch that fails doesn't trigger builds.

## Export everything

`GET /api/v1/spaces/:space_id/export`

Returns the whole site as JSON: content types with all their fields (hidden ones too), each entry with its latest version and its published version, and the files with their URL. It's yours: if you leave, you take it with you.

## AI answers

`GET /api/v1/spaces/:space_id/answers`

`PATCH /api/v1/spaces/:space_id/questions`

`GET` returns the site's questions, the place they're asked from (`market`: `country`, `city`) and the last `run`: how many `answers`, in how many the site is `mentioned` and `cited`, its `average_position`, the `competitors` named in the answers (each with how many answers named it), the `issues` (statements that contradict the site's published content, each with `claim` and `correct`), and `items` with each question's answer and sources. `history` has the last 12 runs. Any key that reads drafts.

`PATCH` replaces the questions: `{"questions": ["…", "…"], "market": {"country": "GT", "city": "Guatemala City"}}`, 1 to 30 questions of up to 200 characters. Needs `manage_model`. See [AI answers](../guides/ai-answers.md).

## Analytics

`GET /api/v1/spaces/:space_id/analytics`

The site's first-party analytics for the last `days` (1 to 365, default 30): `visitors`, `page_views`, `from_ai` (visitors and page views that came from an AI assistant), and top-10 lists of `sources`, `ai_engines`, `referrers`, `pages`, `countries`, `cities`, `campaigns` (UTM) and `devices`, each item with its `count` of visitors. `crawlers` has the AI crawler `visits`, `by_bot` (with `operator` and `purpose`: `search`, `user` or `training`) and the `pages` read for answers. Any key that reads drafts. See [Analytics](../guides/analytics.md).

## Search queries

`GET /api/v1/spaces/:space_id/search_queries`

What people searched on Google and Bing before they saw or clicked the site, from Google Search Console and Bing Webmaster Tools, for the last `days` (1 to 90, default 28). `engine` is `google` or `bing`; without it, both. Any key that reads drafts. Empty until an owner connects the engines in **Settings → Integrations** (it needs their Google or Bing account, so there's no API to connect). See [Search Console and Bing](../guides/search-console.md).

- `connections`: each connected engine with its `site_url` (the property), `synced_at` and `last_error` (`null` when the last sync worked).
- `totals`: per engine, `clicks`, `impressions`, `ctr` (0 to 1) and `position` (average, 1 is the top).
- `queries` and `pages`: the top 50 of each, most clicks first, with `engine`, the `query` or `page`, and the same four numbers.

Classic search only. Neither engine shares its AI answers data (Google's AI Overviews and AI Mode, Bing's Copilot) through its API.

```json
{
  "days": 28,
  "since": "2026-09-03",
  "connections": [{ "engine": "google", "site_url": "sc-domain:harborbakery.example", "synced_at": "2026-10-01T05:10:00Z", "last_error": null }],
  "totals": { "google": { "clicks": 412, "impressions": 9870, "ctr": 0.0417, "position": 11.3 } },
  "queries": [{ "engine": "google", "query": "sourdough near me", "clicks": 96, "impressions": 1240, "ctr": 0.0774, "position": 3.2 }],
  "pages": [{ "engine": "google", "page": "https://harborbakery.example/menu", "clicks": 180, "impressions": 3100, "ctr": 0.0581, "position": 6.4 }]
}
```

## AI readiness

`GET /api/v1/spaces/:space_id/readiness`

`POST /api/v1/spaces/:space_id/readiness`

`POST` reads the site's live URL like an AI crawler and returns each check; `GET` returns the last one. Any key that reads drafts (`read_drafts`). Once a minute at most. Each check has `key`, `title`, `status` (`pass`, `warn` or `fail`), `detail`, `fix_by` (`content` or `site`), `ask_agent` (the instruction to fix it, or `null`) and `doc_url`. See [AI visibility](../guides/ai-visibility.md).

```json
{
  "url": "https://harborbakery.example",
  "checked_at": "2026-10-01T12:00:00Z",
  "passed": 6,
  "total": 9,
  "checks": [
    { "key": "summary-for-ai", "title": "There's a summary for AI (llms.txt)", "status": "warn",
      "detail": "No llms.txt. …", "fix_by": "site", "ask_agent": "Add https://harborbakery.example/llms.txt: …",
      "doc_url": "https://pluma.so/docs/guides/ai-visibility#summary-for-ai" }
  ]
}
```

If the site has no live URL, or the last check was less than a minute ago: [`check_unavailable`](../errors/check_unavailable.md).

## Possible errors

[`key_cannot`](../errors/key_cannot.md), [`check_unavailable`](../errors/check_unavailable.md), [`validation_failed`](../errors/validation_failed.md), [`version_conflict`](../errors/version_conflict.md), [`invalid_body`](../errors/invalid_body.md), plus the [read](delivery.md#possible-errors) ones.

---

<!-- docs/guides/entries.md -->

# Create and publish content

An **entry** is a specific piece of content: the event "Open House", the post "Welcome, families". Each entry belongs to a [content type](content-model.md) and has one value per field.

## The life of an entry

```
draft ──publish──▶ published ──edit──▶ unpublished changes ──publish──▶ published

any state ──archive──▶ archived ──unarchive──▶ draft
```

| Status | What your site sees | What you see in the dashboard |
| --- | --- | --- |
| **Draft** | Nothing | The entry, never published |
| **Published** | The published version | Same as the site |
| **Unpublished changes** | The last published version | Your newest draft, on top |
| **Archived** | Nothing | The entry, grayed out |

- **Everything an agent creates starts as a draft.** Publishing is a separate step, with its own permission.
- **Saving never publishes.** Your site keeps showing the published version until you publish again.

## Create an entry

In your site, go to **Content → New entry** and pick the type. The form builds itself from the type's fields:

| Field type | How you fill it in |
| --- | --- |
| Short text, slug | One line. The slug is suggested from the title |
| Long text, JSON | A text area. The JSON must be valid |
| Rich text | Markdown: `**bold**`, `## heading`, lists, links. Stored as JSON blocks |
| Number, yes/no, date | The matching control for each |
| Tags | Separated by commas |
| Reference(s) | You pick from the entries of the allowed types |

If the type is a **singleton**, it has a single entry: Pluma won't let you create a second one.

## Publish

Publishing validates the whole entry:

- **Required** fields have a value.
- Each value is the right type (a number is a number, a date is a date).
- **Slugs** are unique within the type.
- **References** point to entries that exist and are of an allowed type.

If something fails, it isn't published and you see which field to fix. Saving a draft only checks types, not required fields: you can save halfway.

**Empty text is an empty field.** Sending `""` (or only spaces) leaves the field with no value, and the API doesn't return it. If your site needs `""`, use that as the default when reading.

## Versions

Every time you save, a **new version** is created: number, who (person or agent) and when. Nothing is overwritten.

- You can **restore** any version. Restoring creates a new version with that data; it doesn't delete the ones in between.
- The published version is marked. If you restore an old one, it stays as unpublished changes until you publish.

## Possible errors

| Message | What to do |
| --- | --- |
| "Title is required to publish" | Fill it in, or save as a draft. |
| "That slug is already used by another entry of this type" | Change it. Add the date or a number. |
| "Not a number" / "Not a date" | Check the field format. |
| "This type is a singleton and already has its entry" | Edit the one that already exists. |
| "The reference points to an entry of a type this field doesn't accept" | Pick an entry of the allowed types. |

---

<!-- docs/api/agents.md -->

# Agents

An **agent** is a first-class user: it has a name, its own permissions and its own token. You invite it with a one-time link (see [Invite your agent](../guides/invite-your-agent.md)).

## Read the invite

`GET /join/:token`

No key needed. Returns **markdown** with what Pluma is, which site it is, what you'll be able to do, and the exact command to redeem it.

```sh
curl https://pluma.so/join/$PLUMA_INVITE
```

## Redeem

`POST /join/:token/redeem`

```sh
curl -X POST https://pluma.so/join/$PLUMA_INVITE/redeem
```

```json
{
  "token": "pluma_agt_…",
  "agent": { "name": "María's Claude", "can": ["read_published", "read_drafts", "write", "publish", "manage_model", "manage_keys", "manage_webhooks"] },
  "space": "acton-estero",
  "next": [ "GET /api/v1/spaces/acton-estero/llms.txt", "…" ]
}
```

- The `token` is shown **only once**. Save it as an environment variable (`PLUMA_KEY`), never in the repo.
- The link is now used. Opening or redeeming it again gives [`invite_used`](../errors/invite_used.md); if 24 hours have passed, [`invite_expired`](../errors/invite_expired.md).
- The token works like any key: `Authorization: Bearer pluma_agt_…`.

## Your site's docs

`GET /api/v1/spaces/:space_id/llms.txt`

```sh
curl https://pluma.so/api/v1/spaces/acton-estero/llms.txt -H "Authorization: Bearer $PLUMA_KEY"
```

Markdown generated live: which site it is, its locales, **each content type with its fields and validations**, and the endpoints ready to copy with the slug filled in. It changes as soon as the model changes. Read it before creating or importing anything: you should never have to guess the model.

## Keys for the site

`GET /api/v1/spaces/:space_id/api_keys`

`POST /api/v1/spaces/:space_id/api_keys`

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/api_keys \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Netlify build", "kind": "delivery" }'
```

```json
{ "token": "pluma_dlv_…", "key": { "id": "3", "name": "Netlify build", "kind": "delivery" } }
```

- An agent can create `delivery` and `preview` keys. A `management` one gives [`key_cannot`](../errors/key_cannot.md): an agent can't hand out more permissions than its owner has.
- The `token` is shown once. For the build, save it in the host's environment variables (Netlify, Vercel), not in the code.

## An agent's permissions

An agent has its own permissions, **capped by the current role of the person who connected it** (whoever approved the OAuth or generated the invite link). If that person's role is lowered, the agent loses the same right away; if they're removed from the site, the agent can't do anything. The breakdown by role is in [What the agent can do](../mcp/tools.md#what-the-agent-can-do).

| Permission | What it allows |
| --- | --- |
| `read_published` | Read what's published |
| `read_drafts` | Read drafts and versions |
| `write` | Create and edit entries and files |
| `publish` | Publish, archive, unarchive |
| `manage_model` | Create and edit content types and fields |
| `manage_keys` | Create `delivery` and `preview` keys |
| `manage_webhooks` | Create, test and delete deploy hooks |

---

<!-- docs/guides/connect-mcp.md -->

# Step 3 · Connect the MCP

With the MCP connected, your agent manages content with tools: "publish the Open House", "show me the drafts", "upload this photo". No `curl` and no copying responses.

Every client uses the same address:

```text
https://pluma.so/mcp
```

It's a **remote MCP server over Streamable HTTP**. Always include the `/mcp` path.

## Two ways to sign in

| Way | When | What you do |
| --- | --- | --- |
| **Sign in with Pluma** (OAuth) | Chat apps and anything on your computer with a browser | Paste the URL. A Pluma screen opens: you pick the site and approve. |
| **Agent token** (`pluma_agt_…`) | Servers, CI, containers, anything with no browser | Create an agent in **Agents** ([step 2](invite-your-agent.md)) and send its token in the `Authorization` header. |

With **Sign in with Pluma**, the approval screen always works the same:

1. You sign in to Pluma (if you weren't signed in).
2. You see which client is asking for access and **choose the site**.
3. **You approve.** You go back to your app, connected.

Anyone on the site can approve. The agent can do the same as the person who approved, based on their role: if you're an editor, it creates and publishes content but doesn't touch the model. If your access is limited to some types or entries, so is the agent's. See [What the agent can do](../mcp/tools.md#what-the-agent-can-do).

Each client below has its own section. Pick yours.

## ChatGPT

This is how your clients edit their site by asking ChatGPT. It uses ChatGPT's **developer mode**, available on the web for Plus, Pro, Business, Enterprise and Education.

1. In ChatGPT, open **Settings → Security and login** and turn on **Developer mode**.
2. Go to **Plugins** (chatgpt.com/plugins) and press **+**.
3. Give it a name ("Pluma") and a short description ("Content for our website").
4. Choose **Public endpoint** and paste `https://pluma.so/mcp`.
5. For authentication, choose **OAuth**. ChatGPT registers itself with Pluma; you don't need a client ID.
6. Approve on the Pluma screen. The connector shows up under **Drafts**, ready to use in a chat.

Then ask: "Using Pluma, what pages does my site have?".

- **Business and Enterprise:** a workspace admin has to allow developer mode first (**Workspace settings → Permissions & roles**). Only admins and owners can publish the connector for the whole workspace.
- **Confirmations:** ChatGPT asks you to confirm every tool that changes something (create, publish, delete). That's expected. Reading doesn't ask.
- **No token field:** ChatGPT has no place to paste a header, so use **Sign in with Pluma**, not an agent token.

## Claude (claude.ai, desktop and mobile)

The connection runs from Anthropic's servers, so the same connector works on the web, in the desktop app and on your phone.

**Free, Pro and Max:**

1. Open **Settings → Connectors** and press **Add custom connector**.
2. Name it "Pluma" and paste `https://pluma.so/mcp`.
3. Press **Add**, then **Connect**, and approve on the Pluma screen.

**Team and Enterprise:**

1. An owner opens **Organization settings → Connectors → Add → Custom → Web** and pastes the URL.
2. Each person then goes to **Settings → Connectors**, finds Pluma and presses **Connect**.

Before using it in a chat, turn it on from the **+** menu (Connectors) in the message box.

- **Free plan:** one custom connector.
- **Leave the OAuth fields empty.** Claude registers itself with Pluma.
- **To change how it signs in,** remove the connector and add it again. Claude doesn't let you edit that later.

## Claude Code

```bash
claude mcp add --transport http --scope user pluma https://pluma.so/mcp
```

Then, inside Claude Code, run `/mcp`, choose **pluma** and **Authenticate**. Your browser opens on the Pluma screen.

`--scope user` makes it available in every project. Use `--scope project` to save it in the repo's `.mcp.json` and share it with your team.

**With a token (no browser):**

```bash
claude mcp add --transport http --scope user pluma https://pluma.so/mcp \
  --header "Authorization: Bearer $PLUMA_TOKEN"
```

**Shared in the repo** (`.mcp.json`, each person sets `PLUMA_TOKEN` on their machine):

```json
{
  "mcpServers": {
    "pluma": {
      "type": "http",
      "url": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer ${PLUMA_TOKEN}" }
    }
  }
}
```

## Cursor

Add Pluma to `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (this project):

```json
{
  "mcpServers": {
    "pluma": { "url": "https://pluma.so/mcp" }
  }
}
```

Open **Settings → MCP**. Pluma shows **Needs login**: press it and approve on the Pluma screen.

**With a token:**

```json
{
  "mcpServers": {
    "pluma": {
      "url": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer ${env:PLUMA_TOKEN}" }
    }
  }
}
```

Cursor writes environment variables as `${env:NAME}`, not `${NAME}`.

## VS Code (GitHub Copilot)

Run **MCP: Add Server** from the command palette, choose **HTTP** and paste the URL. Or write `.vscode/mcp.json` yourself:

```json
{
  "servers": {
    "pluma": { "type": "http", "url": "https://pluma.so/mcp" }
  }
}
```

The first time Copilot uses it, VS Code asks you to sign in and opens the Pluma screen. Use it from Copilot Chat in **Agent** mode.

**With a token,** VS Code asks for it once and stores it safely:

```json
{
  "inputs": [
    { "type": "promptString", "id": "pluma-token", "description": "Pluma agent token", "password": true }
  ],
  "servers": {
    "pluma": {
      "type": "http",
      "url": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer ${input:pluma-token}" }
    }
  }
}
```

- **Copilot Business and Enterprise:** the **MCP servers in Copilot** policy is off by default. An admin has to turn it on.

## Windsurf

Open **Settings → Cascade → MCP servers → View raw config** and add Pluma to `mcp_config.json`:

```json
{
  "mcpServers": {
    "pluma": {
      "serverUrl": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer ${env:PLUMA_TOKEN}" }
    }
  }
}
```

Windsurf uses **`serverUrl`**, not `url`. Leave out `headers` to sign in with Pluma instead. On Team and Enterprise, an admin may have to add Pluma to the allowed servers.

## Gemini CLI

```bash
gemini mcp add --transport http --scope user pluma https://pluma.so/mcp
```

Then, inside Gemini CLI, run `/mcp auth pluma` and approve on the Pluma screen.

Or edit `~/.gemini/settings.json` yourself:

```json
{
  "mcpServers": {
    "pluma": {
      "httpUrl": "https://pluma.so/mcp",
      "headers": { "Authorization": "Bearer pluma_agt_…" }
    }
  }
}
```

Gemini CLI uses **`httpUrl`** for this kind of server. `url` means an older transport and won't work. Leave out `headers` to sign in with Pluma. Signing in needs a browser on the same machine, so over SSH or in a container use the token.

## Codex (CLI, IDE extension and ChatGPT desktop app)

All three read `~/.codex/config.toml`:

```toml
[mcp_servers.pluma]
url = "https://pluma.so/mcp"
```

Then sign in:

```bash
codex mcp login pluma
```

**With a token,** name the environment variable that holds it:

```toml
[mcp_servers.pluma]
url = "https://pluma.so/mcp"
bearer_token_env_var = "PLUMA_TOKEN"
```

## Zed

In Zed's `settings.json`:

```json
{
  "context_servers": {
    "pluma": { "url": "https://pluma.so/mcp" }
  }
}
```

Zed opens the Pluma screen the first time. To use a token instead, add `"headers": { "Authorization": "Bearer pluma_agt_…" }`.

## Any other client

If your client supports remote MCP servers, it works with Pluma:

- **URL:** `https://pluma.so/mcp`, transport **Streamable HTTP** (sometimes called "HTTP").
- **Sign in with Pluma:** choose OAuth and leave client ID and secret empty. Pluma supports dynamic registration, so the client registers itself. The technical details are in [MCP OAuth](../mcp/oauth.md).
- **Token:** send the header `Authorization: Bearer pluma_agt_…`. The word `Bearer` goes in the value.

If the client only runs local (stdio) servers, bridge it with `mcp-remote`:

```json
{
  "mcpServers": {
    "pluma": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://pluma.so/mcp", "--header", "Authorization:Bearer ${PLUMA_TOKEN}"],
      "env": { "PLUMA_TOKEN": "pluma_agt_…" }
    }
  }
}
```

## Check that it works

Ask your agent: "Using Pluma, describe my site". It should answer with your content types.

To see exactly what it can do, ask: "Using Pluma, what can you do on my site?". The list of tools depends on the role of whoever connected it: a viewer's agent sees only reading tools.

## Troubleshooting

| What you see | Why | What to do |
| --- | --- | --- |
| The client says it can't connect | The URL is missing `/mcp`, or the transport is SSE | Use `https://pluma.so/mcp` with Streamable HTTP. |
| "The return address isn't the one the client registered" | The client changed its callback after registering | Remove the server from the client and add it again. |
| "This server only authorizes …/mcp" | The client asked for access to a different server | Check the URL you pasted. |
| `401` with "The key is missing" | No token was sent | Sign in again, or check the `Authorization` header. |
| `401` with "The key doesn't exist or was revoked" | Someone revoked the agent in **Agents**, or the token is incomplete | Connect again, or create a new agent. |
| The agent can't publish or change the model | The person who approved doesn't have that role, or has limited access | Ask an owner to connect it, or to change your role in **Settings → Team**. |
| You don't see your site on the approval screen | You're not a member of that site | Ask an owner to invite you. |

## Revoke

Each connected client shows up in **Agents** with its name (for example "Claude Code" or "ChatGPT"). Revoking it cuts access right away. Connecting again creates a new agent.

## What you see in the dashboard

Step 3 checks itself off when the **first authenticated MCP call** from your site arrives. In the activity, what your agent does over MCP shows up with its name and the "agent" tag, same as over the API.

The available tools and what each one does are in [MCP tools](../mcp/tools.md).

---

<!-- docs/guides/files.md -->

# Images and files

An **asset** is a file in your site: a photo, a logo, a PDF, a short video. You upload it once and use it in any entry.

## Upload

In your site, go to **Files → Upload**. You can upload several at once.

- Images: JPG, PNG, WebP, GIF and [SVG](#svg). Documents: PDF. Up to **20 MB**.
- Video: MP4 and WebM, up to **50 MB**. For long videos, it's better to use YouTube or Vimeo and store the link in a text field.
- Over the API you can also upload from a URL, and many at once with a [batch](../api/management.md#batch).

Each file has:

| Field | What it's for |
| --- | --- |
| Title | How you find it in the list. Suggested from the file name |
| Alt text | What a screen reader reads and what Google sees. **Required for images before using them in a published entry** |
| Description | Optional |

A file has no draft: as soon as it's uploaded, it can be used. What gets published is the entry that uses it.

### SVG

An SVG is code, not just an image: it can carry scripts or load things from another site. Pluma accepts SVGs that are **drawing only**, and rejects, saying why, the ones that contain:

- `<script>`, `<foreignObject>`, `<iframe>`, `<embed>` or `<object>`;
- attributes that run code (`onload`, `onclick`…) or `javascript:`;
- links or CSS that load something from outside (`href` to another site, `@import`, `url(https://…)`). Internal links (`#id`) and base64-embedded images are fine;
- XML entities (`<!ENTITY>`).

Nothing is cleaned up silently: if your SVG doesn't pass, export it again as "optimized SVG" (in Figma or Illustrator) or run it through SVGO. On top of that, Pluma serves it with a policy that doesn't let anything run even if something slips through.

## Use it in an entry

- **`asset` or `assets` field:** in the entry form you pick the image from the grid.
- **Inside a rich text:** write `![alt text](asset:ID)` in its own paragraph. The ID is on the file's page. It is stored as an `image` block that points to the asset; see [Rich text](../reference/rich-text.md).

Publishing checks that each file used exists and, if it's an image, has alt text.

## A file's URL

`fields.file.url` in the API looks like `https://pluma.so/files/acton-estero/4-k3x9q2m7ab/playground.jpg`: your site, the file's id with a short random key, and its name.

- It **doesn't change while the file stays the same**, and it's served with a cache that never expires. If you [replace the file](../api/management.md#edit-or-replace-a-file), the URL changes, so no cache shows the old one: read it again from the API.
- The domain is `pluma.so` (until October 1, 2026 it was `pluma.fly.dev`, which still serves the same files). If your framework only optimizes images from allowed domains (Astro `image.domains`, Next `images.remotePatterns`), allow both. See the [Astro recipe](../frameworks/astro.md#3-images).
- The random key makes it impossible to guess: nobody finds an unpublished photo by trying ids.
- In your site build you can use it directly, or download the files and serve them yourself from your usual paths (that's what the [Astro recipe](../frameworks/astro.md) does).

With a `delivery` key you only see the files used by something **published** in an `asset`/`assets` field or inside a rich text. A file id stored inside a `json` field doesn't count: if an image is used, put it in an `asset` field.

## Metadata

Photos carry hidden data: the camera, the date and often the **GPS position** where they were taken. File URLs are public, so Pluma removes that data from JPG, PNG and WebP images before storing them.

- The photo looks the same: if the camera stored it sideways with a rotation flag, Pluma rotates the pixels first. Width and height are the ones you see.
- The color profile stays, so colors don't change.
- An image with no metadata is stored exactly as you uploaded it. One with metadata is saved again at high quality.
- GIF, SVG, PDF and video are stored as they are.

If you need a photo's EXIF (the date it was taken, for example), save it in a field of the entry.

## Delete

A file used by any entry **can't be deleted**: Pluma tells you which entries use it. Remove it from those entries first.

## Where they are stored

Today, on the disk of Pluma's machine, with a daily backup. When Cloudflare R2 storage is ready (no egress fees), files will be served from there and behind a CDN; the API URLs won't change shape.

## Possible errors

| Message | What to do |
| --- | --- |
| "That file type isn't accepted" | Convert it to JPG, PNG, WebP, GIF, SVG, PDF, MP4 or WebM. |
| "The file is larger than 20 MB" (50 MB for video) | Compress it. For photos, 2000 px wide is enough; for video, 720p. |
| "This SVG isn't accepted: …" | The message says what it contains. Export it optimized; see [SVG](#svg). |
| "Alt text is missing" | Describe the image in one sentence. |
| "These entries use it" | Remove it from them and try again. |

---

<!-- docs/api/webhooks.md -->

# Webhooks

Deploy hooks through the API. Needs the `manage_webhooks` permission (agents and `management` keys have it). What Pluma sends and how to verify it: [Generic webhook deploy](../guides/deploy-webhook.md).

## List

`GET /api/v1/spaces/:space_id/webhooks`

## Create

`POST /api/v1/spaces/:space_id/webhooks`

```sh
curl -X POST https://pluma.so/api/v1/spaces/acton-estero/webhooks \
  -H "Authorization: Bearer $PLUMA_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Netlify", "kind": "netlify", "url": "https://api.netlify.com/build_hooks/abc123" }'
```

```json
{ "webhook": { "id": "1", "name": "Netlify", "kind": "netlify", "url": "https://api.netlify.com/build_hooks/abc123",
               "target": "production", "events": ["entry.published", "entry.unpublished", "content_type.changed"], "last_delivery": null },
  "secret": "whsec_…" }
```

- `kind`: `netlify`, `vercel` or `generic`. `events` is optional.
- `target`: `production` (default) rebuilds the live site when something is published. `preview` is for a [staging site](../guides/staging.md): it also rebuilds on every save (`entry.saved`), so drafts show up there.
- Events: `entry.published`, `entry.unpublished`, `content_type.changed`, `asset.replaced` (a file that something published uses got a new version) and `entry.saved`. A `production` hook listens to all but `entry.saved` unless you say otherwise; a `preview` hook always includes `entry.saved`.
- The `secret` to verify the signature is shown **only once**.
- The URL must be `https://` and public.

## Test

`POST /api/v1/spaces/:space_id/webhooks/:id/test`

Sends a test hook **right away** and returns what the destination answered:

```json
{ "delivery": { "id": "7", "status_code": 200, "ok": true, "response_ms": 184, "error": null, "test": true } }
```

A hook with a 2xx response checks off step 5 of the checklist.

## Delete

`DELETE /api/v1/spaces/:space_id/webhooks/:id`

---

<!-- docs/frameworks/fetch.md -->

# Plain fetch

For any language or framework: the [Delivery API](../api/delivery.md) is a `GET` with your key. No SDK.

```js
const res = await fetch(
  `https://pluma.so/api/v1/spaces/${process.env.PLUMA_SPACE}/entries?content_type=event&order=fields.startsAt&rich_text=html`,
  { headers: { Authorization: `Bearer ${process.env.PLUMA_DELIVERY_KEY}` } }
);
if (!res.ok) throw new Error((await res.json()).error.message);
const { items } = await res.json();
for (const event of items) console.log(event.fields.title, event.fields.startsAt);
```

- **The key goes on the server or in the build**, never in code that runs in the browser: `PLUMA_DELIVERY_KEY` as an environment variable.
- **`rich_text=html`** gives you long text already as HTML; `markdown` if your site converts it; without the parameter, the [JSON blocks](../reference/rich-text.md).
- **References and images in the same response:** `include=1` brings them in `includes.Entry` and `includes.Asset`. See [Delivery API](../api/delivery.md).
- **Pagination:** up to 1000 per request with `limit`; the rest with `skip`. `total` says how many there are.
- **Cache:** with a `delivery` key, responses come with an `ETag`. Send `If-None-Match` and, if nothing changed, you get a `304` with no body.
- **Errors:** always `{ "error": { "code", "message", "fix", "doc_url" } }`. See [Errors](../errors/index.md).

---

<!-- docs/guides/team.md -->

# Step 4 · Invite your team

Add the people who write or review the content. Users are **unlimited** on every site.

## Roles

| Role | Content | Model (types and fields) | Keys and agents | People | Billing | Delete the site |
| --- | --- | --- | --- | --- | --- | --- |
| **Owner** | Yes | Yes | Yes | Yes | Yes | Yes |
| **Admin** | Yes | Yes | Yes | Yes | No | No |
| **Editor** | Create, edit and publish | No | No | No | No | No |
| **Author** | Create and edit; **can't publish** | No | No | No | No | No |
| **Viewer** | Read only | No | No | No | No | No |

- Owners and admins see **Overview, Content, Visibility, Assistants and Settings** (Settings holds Team, Deploy, Keys and Integrations). Editors, authors and viewers see **Overview, Content and Assistants**; Model, Visibility and Settings are only for owners and admins, and opening them by URL redirects to Overview.
- **Assistants** is where anyone on the site connects their own ChatGPT, Claude or other assistant over MCP, and disconnects the ones they connected.
- A person can have different roles on different sites.
- An agent connected by a person **can never do more than that person**, with their current role. If their role is lowered or they are removed from the site, their agent loses the same right away. See [What the agent can do](../mcp/tools.md#what-the-agent-can-do).

## Invite

1. In your site, go to **Settings → Team → Invite**. Enter the email and the role.
2. Pluma creates a **single-use link, valid for 7 days**, and shows it to you to copy. Send it however you like (email, WhatsApp). Automatic sending by email comes once the email service is connected.
3. The person opens the link:
   - If they don't have an account, they create it right there with that email.
   - If they already have one, they sign in and accept.
4. They show up in **Settings → Team** with their role. Pending invitations can be **revoked**.

The link only works for the email you invited.

## Change roles and remove people

- Owners and admins change roles and remove people.
- Only an **owner** can make someone else an owner or change an owner.
- A site is never left without an owner: the last one can't be removed or demoted.

## Limit what someone can edit

An editor, author or viewer can be limited to **part of the site**: in **Settings → Team**, open their **Access** and pick

- **content types** they can create and edit (only Events and Blog posts), and/or
- **single entries** they can edit (only the Admissions page).

Leave everything unchecked for the whole site. Owners and admins always cover the whole site.

- **Outside their access** they can look at entries in the dashboard, but not change them. Their assistant only sees what's published there, like the public site.
- **It never adds to their role:** an author limited to Events still can't publish.
- **Their agents get the same limit**, at once: in the API, the MCP and a [batch](../api/management.md#batch), a change outside it gives [`key_cannot`](../errors/key_cannot.md) saying what they *can* change.

## Delete a site

Only an **owner** can. In **Settings → General**, at the bottom, press **Delete site…** and type the site's name.

It deletes all of its content, files, keys, agents and deploy hooks, and removes everyone's access. There is no undo, so [export it](../api/management.md#export-everything) first if you want a copy.

## Your account

In **Account** (top bar) you change your **name**, your **email** (it asks for your current password, since you sign in with it) and your **password** (it asks for the current one; every other device is signed out).

## Language

The dashboard is in **English** and **Spanish**. Each person picks theirs in **Account** → **Language**; until they do, Pluma follows the browser (Spanish if it asks for Spanish, English otherwise). It changes only the dashboard and its emails: the docs, the API and the MCP stay in English, and your content keeps its own locales. For now the Content section and the Sites list are still in English.

## Delete your account

In **Account**, at the bottom, press **Delete account…** and enter your password.

- Your name and email are erased, and you're signed out everywhere.
- The assistants you connected lose access right away.
- What you did stays in each site's history as "Deleted user".
- If you own a site, delete it or make someone else the owner first.

## Signing in

You stay signed in on each browser until you sign out, or until you don't use Pluma there for **30 days**. After that you sign in again, and Pluma deletes that sign-in (with the IP and browser name it kept) the next day.

## Step 4 of the checklist

It checks itself off when someone **accepts an invitation**. If you work alone, click **"Just me for now"** in Team and the step is done.

---

<!-- docs/guides/deploy-netlify.md -->

# Step 5 · Deploy on Netlify

Pluma doesn't build your site: it **notifies** whoever builds it. When you publish something, Pluma calls a Netlify *build hook* and Netlify rebuilds the site with the new content.

## 1. Create the build hook in Netlify

In Netlify, on your site: **Site configuration → Build & deploy → Build hooks → Add build hook**. Give it a name ("Pluma") and the branch (`main`). Netlify gives you a URL like:

```
https://api.netlify.com/build_hooks/65f0c1…
```

## 2. Paste it into Pluma

In your Pluma site: **Deploy → New deploy**, choose **Netlify** and paste the URL. (Your agent can do it through the API or MCP, see [Webhooks](../api/webhooks.md).)

Pluma sends a test ping when you save and shows you what Netlify answered. If it answered OK, **step 5 of the checklist is done**.

## 3. The site's environment variables

In Netlify: **Site configuration → Environment variables**, add:

| Variable | Value |
| --- | --- |
| `PLUMA_URL` | `https://pluma.so` |
| `PLUMA_SPACE` | Your site's slug, for example `acton-estero` |
| `PLUMA_DELIVERY_KEY` | A `delivery` key (Pluma → Keys) |

Your site uses them to read the content during the build.

## How it behaves

- **One build per batch:** if you publish 20 entries in a row, you get **a single** build, 30 seconds after the last one.
- **Retries:** if Netlify doesn't answer OK, Pluma retries up to 5 times, waiting longer each time.
- In **Deploy** you see every ping: when, what Netlify answered and how long it took. The site's home page shows the status of the latest one.

## See drafts before publishing

For a preview with drafts, use the [Preview API](../api/preview.md) in a Netlify *deploy preview*: in the `deploy-preview` context, set `PLUMA_DELIVERY_KEY` to a `preview` key instead of a `delivery` one. That deploy shows the latest version of each entry; production still shows only what is published.

---

<!-- docs/guides/deploy-vercel.md -->

# Deploy on Vercel

Same as [Netlify](deploy-netlify.md), with a Vercel *Deploy Hook*.

1. In Vercel, in your project: **Settings → Git → Deploy Hooks**. Name "Pluma", branch `main`. It gives you a URL like `https://api.vercel.com/v1/integrations/deploy/prj_…/…`.
2. In Pluma: **Deploy → New deploy**, choose **Vercel** and paste the URL. Pluma sends a test ping and shows you what it answered.
3. In Vercel: **Settings → Environment Variables**, add `PLUMA_URL`, `PLUMA_SPACE` and `PLUMA_DELIVERY_KEY` (a `delivery` key). For *preview deployments*, use a `preview` key.

One build per batch of publishes, retries, and the history in **Deploy**: same as Netlify.

---

<!-- docs/guides/deploy-webhook.md -->

# Generic webhook deploy

For any other host or CI: Cloudflare Pages, GitHub Actions, your own server, a Slack channel. Pluma sends a `POST` with JSON when something happens, signed so you can verify it comes from Pluma.

## What it sends

```http
POST /your-endpoint
Content-Type: application/json
User-Agent: Pluma-Webhooks/1
X-Pluma-Event: publish
X-Pluma-Delivery: 42
X-Pluma-Signature: sha256=5d1f…
```

```json
{
  "space": "acton-estero",
  "events": [
    { "type": "entry.published", "entry_id": "12", "content_type": "event", "at": "2026-09-28T12:00:00Z" }
  ],
  "triggered_at": "2026-09-28T12:00:30Z",
  "test": false
}
```

`events` holds everything that happened in the batch (see debounce below).

## Events

| Event | When |
| --- | --- |
| `entry.published` | An entry was published |
| `entry.unpublished` | A published entry was unpublished or archived |
| `content_type.changed` | A content type or its fields were created or changed |

Each webhook chooses which ones it listens to. By default, all three.

## Verify the signature

`X-Pluma-Signature` is `sha256=` + the HMAC-SHA256 of the **body exactly as it arrived**, using the webhook's secret. The secret is shown only once, when you create the webhook.

```js
import crypto from "node:crypto";
const expected = "sha256=" + crypto.createHmac("sha256", process.env.PLUMA_WEBHOOK_SECRET).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-pluma-signature"]));
```

## Rules

- The URL must be **`https://`** and point to the public internet: Pluma doesn't call private addresses or `localhost`.
- **30-second debounce:** a batch of publishes goes out in a single ping.
- **Retries:** up to 5 attempts if the response isn't 2xx or doesn't arrive within 10 seconds, waiting longer each time.
- Every attempt is logged in **Deploy**, with the response code and how long it took.

---

<!-- docs/guides/composable-pages.md -->

# 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](content-model.md#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_item`s and the images in the same response. If a page in the code already uses the slug, the code's page wins.

In Astro:

```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](../frameworks/astro.md).

## 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_item`s, the sections and the page in one [batch](../api/management.md#batch) (with `"$ref"` to link them without knowing the ids), publishes it, and the [deploy hook](deploy-netlify.md) 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](content-model.md#agent-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: &lt;type&gt;**. On later runs it [replaces that same file](../api/management.md#edit-or-replace-a-file), so nothing piles up and nothing published is rebuilt. It also makes sure the type lists it in its previews.

```js
// 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.

---

<!-- docs/guides/migrate-from-files.md -->

# Migrate from repo files

This guide is for your **agent**. It's the "match": turning the content that lives today in repo files (JSON, markdown with frontmatter, config) into Pluma content types and entries, without losing anything and without breaking the site.

## 1. Find the content

Search the site's repo, in this order:

| What to look for | Examples | In Pluma |
| --- | --- | --- |
| Site config | `site-config.js`, `config.json`, `_data/site.yml` | A **singleton** content type (`site_settings`) |
| Collections | `src/content/blog/*.md`, `content/events/*.json` | One content type per collection |
| Frontmatter | `title`, `date`, `slug`, `tags`, `image` | One field per key |
| Markdown body | Whatever comes after the frontmatter | A `rich_text` field |
| Images | `public/images/…`, `src/assets/…` | Files, uploaded once and referenced |

## 2. Propose the model, and show it before creating anything

Put together the list of types with their fields and **show it to the site owner before creating anything**. If the owner already told you to go ahead and publish ("I trust you"), that's the approval: say what you're about to create and continue. Mapping rules:

- One-line text → `symbol`. Long plain text → `text`. Markdown → `rich_text`.
- Dates → `date`. Numbers → `integer` or `number`. `true/false` → `boolean`.
- Lists of text (tags, categories) → `tags`.
- The URL identifier → `slug`, required.
- One image → `asset`; several → `assets`.
- Something that points to other content (author, category with its own page) → `reference`, with `link_content_types`.
- If a key appears only in some files, the field is not required.
- Field names: the same as in the frontmatter, in camelCase. That keeps the change in the site's code to a minimum.
- The type's API ID: **singular and in snake_case**: `blog_post`, `event`, `site_settings` (the `blog/` collection becomes the `blog_post` type).
- Rows of the same small shape (a menu, opening hours, social links) → a [`list`](content-model.md#lists) field with its item fields, not `json`: the owner edits them row by row. Keep `json` for data nobody edits by hand.
- **Dates:** send them as they are in the files. A date without a time (`2026-08-14`) stays without a time; one with a time zone (`-06:00`) keeps the zone. Don't convert them to UTC. Within the same field, always use the same format.
- **Author without their own page:** text (`symbol`). With their own page: their own type and a `reference`.
- **Links between pages inside markdown** (`[our rye](/menu/rye-seeded)`): rewrite them as [`entry:ID` links](../reference/rich-text.md#links-to-entries) once the target exists, so they don't break when a slug changes.
- **Menu and social links** from the config: a `list` field inside the singleton. If each item needs its own page or photo, a type of its own (`menu_item`) with references is better.
- **Site config:** a type with `"singleton": true`; Pluma rejects a second entry of that type.

## 3. Create the types

With `dry_run=true` first, then for real. See [Management API](../api/management.md). Read your site's doc (`GET /api/v1/spaces/:space_id/llms.txt`) to confirm it came out the way you wanted.

## 4. Import

- **Images first** (`POST /assets`), with a title and alt text. Keep the mapping `file path → asset id`.
- **Then the entries**, replacing image paths with asset ids: in `asset` fields goes the id; inside markdown, `![alt](asset:ID)`.
- References need the entry they point to to exist: import first what others reference (authors before posts).
- **Use the [batch](../api/management.md#batch)** for entries: up to 100 operations per request, all or nothing, and with `"$ref"` a post points to the author created in the same batch. The pilot (73 entries, 66 images) took 212 one-at-a-time calls; with batches it's just a few.
- Everything starts as a draft. **Publish only after the owner has reviewed it.**

## 5. Connect the site

- Create a `delivery` key (see [Agents → Keys for the site](../api/agents.md#keys-for-the-site)) and ask the owner to store it in the host's environment variables.
- Request the body with `rich_text=html` (or `markdown`): images already come with their URL. If your framework renders markdown, `markdown` changes less code.
- In the site's code, change **a single place** (the one that reads the files) so it reads from the Delivery API, with the files as a fallback while the trial lasts. Recipes in Frameworks.
- Do it on a branch and open a PR. Never straight to main. If the site isn't in git, edit the file and describe the change in the report.

## 6. Report

Leave the owner a summary: types created, entries imported per type, files uploaded, and **everything you couldn't map**, with the file and the reason.

---

<!-- docs/guides/staging.md -->

# Staging and previews

See a draft on your real site before you publish it. Works with any host: Netlify, Vercel, Cloudflare Pages, your own server.

The idea: **a second build of your site** (staging) that reads with a `preview` key, so it shows the latest version of everything, drafts included. Pluma rebuilds it every time something is saved, and every entry gets a **View on staging** link.

## 1. Build a staging site

Deploy the same site a second time, reading from Pluma with a **preview key** instead of the delivery key. With Netlify or Vercel that's usually a branch deploy or a second site with different environment variables:

| Variable | Live site | Staging site |
| --- | --- | --- |
| `PLUMA_SPACE` | your site | your site |
| `PLUMA_DELIVERY_KEY` | a `delivery` key (published only) | a `preview` key (latest, drafts included) |

The code doesn't change: a `preview` key answers the same [Delivery API](../api/delivery.md) with the latest version of each entry. See [Preview API](../api/preview.md).

Keep the staging site private (password or `noindex`): it shows unpublished content.

## 2. Tell Pluma where both sites live

In your site, **Settings → General**: the live site (`https://www.example.com`) and the staging site (`https://staging.example.com`). By API: `PATCH /api/v1/spaces/:space_id` with `production_url` and `preview_url`. By MCP: `update_site`.

## 3. Tell Pluma where each type lives

In each content type, **Page URL on your site**: `/blog/{slug}`, `/events/{slug}`, `/{slug}`. `{slug}` is the entry's slug field, `{id}` its id. By API or MCP it's the type's `url_path`.

With the URLs and the path, each entry gets:

- in the dashboard, **View on staging** and **View live** links;
- in the API and MCP, `sys.url` (live, only when published) and `sys.preview_url` (staging, only with a key that reads drafts). Your agent can hand you the exact link after a change.

## 4. A deploy hook for staging

In **Deploy → New deploy hook**, pick your host, paste the staging site's build hook and choose **The staging site**. A staging hook rebuilds on every save (`entry.saved`) as well as on publish, so a draft shows up there a minute later. Saves close together go out as a single build, 30 seconds after the last one.

By API: `POST /api/v1/spaces/:space_id/webhooks` with `"target": "preview"`. See [Webhooks](../api/webhooks.md).

Your live hook keeps rebuilding only when something is published: drafts never reach the live site.

## Is it set up?

When you save the site URLs (API `PATCH` or MCP `update_site`), the response includes `warnings` if something is missing: a staging URL without a staging hook, or no type with a `url_path` yet. The Deploy page shows the same warnings.

---

<!-- docs/guides/languages.md -->

# Languages

A site has one **default language**: the one its content is written in. Add more and every field marked **Localized** gets one value per language, while the rest (prices, photos, dates) keep a single value for all of them. Your site asks for a language with `?locale=en`.

## The default language

It's what your content is written in, and what the API returns when nobody asks for a language. Pluma guesses it when you create the site, from your browser's language, and again from the content itself when it first reads your site for [Visibility](ai-answers.md), as long as nobody picked one and the site has a single language.

To change it: **Settings → General → Content language**. By API, `PATCH /api/v1/spaces/:space_id` with `{"default_locale": "es"}`; by MCP, `update_site` with `default_locale`.

Changing it **relabels** the content, it doesn't translate it: every value stored under the old language moves to the new one, in every entry and every version (published ones too), and the language keeps its place. Use it when the content was stored under the wrong language, like a Spanish site that started as `en`.

- If an entry already has values in **both** languages, nothing changes and you get [`validation_failed`](../errors/validation_failed.md) with the entries that clash: moving would overwrite one of them. Remove the other language first, or empty those values.
- Your deploy hooks get `content_type.changed`, so the site rebuilds.

## Add or remove languages

**Settings → Languages.** Each language has:

| | What it is | Example |
| --- | --- | --- |
| Code | Two letters, or two plus a region | `es`, `en`, `pt`, `fr`, `en-US` |
| Name | What the dashboard shows. By default, the language's own name | English |
| Fallback | What to show when a value is missing in this language. By default, the default language. It can be none | `es` |

**Removing a language deletes its values** from every entry and every version, published ones too. The dashboard asks first and says how many entries have values in it. The default language can't be removed: make another one the default first.

By API:

```json
POST /api/v1/spaces/:space_id/locales
{ "code": "en", "name": "English", "fallback_code": "es" }
```

`PATCH /api/v1/spaces/:space_id/locales/:code` changes `name` or `fallback_code`. `DELETE /api/v1/spaces/:space_id/locales/:code` removes it; if it has values, it refuses unless you send `?delete_values=true`. All three need `manage_model`. By MCP: `add_locale`, `update_locale`, `remove_locale`. See [Management API](../api/management.md#languages).

## In the dashboard

With more than one language, each entry has a tab per language, like **Español | English**:

- The default tab has every field, as always.
- The other tabs have only the localized fields. Empty ones show the fallback's value as a placeholder, which is what your site gets until someone writes them.
- Each tab says how many fields are **not translated** yet: localized fields with a value in the default language and none in that one.
- Fields that aren't localized show once, in the default tab.

One **Save** keeps every tab, as one new version. Turn **Localized** on or off for a field in **Model**.

## Translate with AI

In a language's tab, **Translate with AI** fills the fields still missing in that language from the default one, and saves them as a **new draft version**. It never publishes: read it, fix what you'd say differently, then publish.

- **Text, long text and tags** are translated.
- **Rich text** keeps its structure: the same headings, lists, links and images. If the translation comes back with a different structure, that field is left as it was and Pluma says so.
- **Slugs** get a translated slug (`menu-del-dia` → `daily-menu`), made unique among the type's entries in that language.
- **Lists** get their text translated; numbers and dates in them stay.
- **References, files, numbers, dates and yes/no** stay as they are: they show the default language's value.

When every field is translated, the button becomes **Translate again**, which replaces what's there (as a new version: the old one stays in Versions).

By API, `POST /api/v1/spaces/:space_id/entries/:id/translate` with `{"to": "en"}` (and `"overwrite": true` to translate every field again). By MCP, `translate_entry`. If the AI provider is down you get [`translation_unavailable`](../errors/translation_unavailable.md) and nothing is saved.

## By API

Reading, with any key:

- Without `locale`, fields come in the default language.
- `?locale=en`: in English. A missing value falls back to the language's fallback; fields that aren't localized always come in the default language's value.
- `?locale=*`: every language, as `{ "title": { "es": "Menú del día", "en": "Daily menu" } }`.
- Filters and sorting (`fields.slug=daily-menu`, `order=fields.title`) use the language you asked for, so a site finds an entry by its English slug.

Writing, with a management key or an agent token, the same `locale`:

```json
PATCH /api/v1/spaces/:space_id/entries/12?locale=en
{ "fields": { "title": "Daily menu", "slug": "daily-menu" } }
```

```json
PATCH /api/v1/spaces/:space_id/entries/12?locale=*
{ "fields": { "title": { "es": "Menú del día", "en": "Daily menu" } } }
```

- Only the fields and languages you send change. `null` empties that field in that language, and the others keep theirs.
- A field that isn't localized only takes the default language: sending it in another one gives `validation_failed`.
- Saving checks the types in every language. **Publishing requires the required fields in the default language only**; a missing translation falls back. Slugs have to be unique within the type in each language.
- With `locale=*`, errors for a language other than the default come as `title.en`.
- In a [batch](../api/management.md#batch), `create_entry` and `update_entry` take `"locale"` the same way. The MCP's `create_entry`, `update_entry`, `get_entry` and `list_entries` too.

## URLs

In a type's URL path, `{locale}` becomes the language: with `/{locale}/menu/{slug}`, the English version of an entry links to `/en/menu/daily-menu`, and `sys.url` and `sys.preview_url` come in the language you asked for, with its slug.

The default language goes **without prefix** by default: `/menu/menu-del-dia`. To have it too (`/es/menu/menu-del-dia`), turn on **Put the default language in URLs too** in **Settings → Languages**, or send `"prefix_default_locale": true` to `PATCH /api/v1/spaces/:space_id` or `update_site`. A URL path without `{locale}` is the same for every language, with each language's slug.

---

<!-- docs/guides/migrate-from-contentful.md -->

# Migrate from Contentful

> Pending page. It gets written in phase 9, before the code it describes.

This page will cover: exporting from Contentful and importing into Pluma, field by field.

---

<!-- docs/guides/billing.md -->

# Billing

> Pending page. It gets written in phase 6, before the code it describes.

This page will cover: price, the 14-day trial, what happens if you stop paying (the Delivery API keeps serving).

---

<!-- docs/guides/ai-visibility.md -->

# AI visibility

When someone asks ChatGPT, Claude or Perplexity about what you sell, the assistant reads websites first and then answers. If it can't read yours, it recommends someone else.

**Visibility** in your site's dashboard reads your live site the same way an AI crawler does and tells you what's missing. Each problem says **who fixes it** (your content in Pluma, or your site's code) and gives you a sentence to hand your agent.

- It needs the site's live URL: set it in **Settings → General** or with `update_site`.
- It reads only public `https` pages and changes nothing on your site.
- Anyone on the site can run it, once a minute at most.
- Agents use it too: `check_readiness` runs it and `get_readiness` reads the last result ([MCP tools](../mcp/tools.md)). In the API, `GET` and `POST /api/v1/spaces/:space_id/readiness` ([Management API](../api/management.md#ai-readiness)).

These are the checks, in the order the page shows them.

## Reachable

Your home page answers with a 200. An assistant can't read a site that doesn't load, redirects in a loop, or takes more than 8 seconds.

## Title and description

The home page has a `<title>` and a `<meta name="description">`. Assistants read them first to know what the business is. Write one plain sentence: what you sell, where, and for whom. It lives in your content, so you can change it in Pluma.

## Search bots

`robots.txt` doesn't block the bots that assistants use to read and cite pages: `OAI-SearchBot` and `ChatGPT-User` (ChatGPT), `PerplexityBot` (Perplexity), `Claude-SearchBot` and `Claude-User` (Claude). Blocking one hides you from that assistant.

Training bots (`GPTBot`, `ClaudeBot`, `Google-Extended`, `Applebot-Extended`, `CCBot`) are a separate choice: blocking them keeps your pages out of model training and doesn't hide you from answers. The check lists them but doesn't fail for them.

## Content signals

A `Content-Signal` line in `robots.txt` says how AI companies may use your pages:

```text
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Allow: /
```

`search` is for search results, `ai-input` for answering questions, `ai-train` for training models. Not every company honors it yet, so it's a warning, not a failure.

## Sitemap

A `sitemap.xml` lists every public page so crawlers find all of them, not only the ones linked from the home page. Pluma looks for the `Sitemap:` line in `robots.txt` first, then `/sitemap.xml`.

## Summary for AI

An `llms.txt` at the root of your site: a short Markdown page with the business name as the title, one paragraph saying what it is, and links to the main pages ([llmstxt.org](https://llmstxt.org)). Build it from your content in Pluma so it stays up to date.

## Markdown

When a request says `Accept: text/markdown`, your pages answer in Markdown instead of HTML. Assistants read it faster and with less noise. If your site builds from Pluma, it already has the content to render.

## Structured data

JSON-LD on the home page that says who the business is: `LocalBusiness` (or the closest type, like `Bakery` or `Restaurant`), `Organization` or `Person`, with name, address, phone, opening hours, logo and social links. Assistants use it for "where", "when" and "how much" answers ([schema.org](https://schema.org/LocalBusiness)).

## MCP card

A file at `/.well-known/mcp/server-card.json` that describes an MCP server for your site, so agents can discover actions it offers (booking, ordering). Optional for now: the standard is still a draft.

---

<!-- docs/guides/analytics.md -->

# Analytics

See who visits your site and where they came from, including **which AI assistant sent them** (ChatGPT, Perplexity, Gemini, Claude, Copilot…) and **which AI crawlers read which pages**. It all lives in Pluma: no Google Analytics, no cookies, no third parties.

You see it in **Visibility** in your site's dashboard, your agent sees it with `get_analytics` ([MCP tools](../mcp/tools.md)), and the API has it at `GET /api/v1/spaces/:space_id/analytics` ([Management API](../api/management.md#analytics)).

There are two parts, because they see different things:

| Part | What it sees | Where it runs |
| --- | --- | --- |
| **The snippet** (`pluma.js`) | People: pages, sources, AI assistants, UTM campaigns, country and city, device | In the visitor's browser |
| **AI crawler reports** | Bots: GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Googlebot, bingbot and more | On your site's server. Crawlers don't run JavaScript, so a script can't count them |

Both need the site's live URL (**Settings → General**): Pluma only counts pages on that domain.

## Add the snippet

Put these two lines before `</body>` on every page. Use your site's slug; the dashboard shows them ready to copy.

```html
<script defer data-site="your-site" src="https://pluma.so/s/your-site/pluma.js"></script>
<noscript><img src="https://pluma.so/s/your-site/p.gif" alt="" width="1" height="1"></noscript>
```

The script counts people. The invisible image is for crawlers, which read the page without running scripts (see [AI crawlers](#ai-crawlers)).

- It sends one small request per page and nothing else. In single-page apps it also counts each route change.
- It does nothing on `localhost`.
- Sites built from the Pluma template already have it.
- **The easiest way:** in **Visibility → Traffic**, press **Copy for your assistant** and paste it to your agent. It follows this page and installs it.

The first visit shows up in **Visibility** within seconds. That's how you know it works.

## AI crawlers

AI crawlers don't run JavaScript: Vercel and MERJ looked at hundreds of millions of their requests and none executed it ([The rise of the AI crawler](https://vercel.com/blog/the-rise-of-the-ai-crawler)). That's why no script-based analytics (Google Analytics, Plausible, this snippet) can count them. The standard way is the server's or the CDN's logs.

**With the snippet alone you get a sample.** Some crawlers download files from the page without running them: in that study, 11.5% of ChatGPT's fetches were JavaScript, and 24% of Claude's were JavaScript and 35% images. When a crawler downloads the snippet's script or its invisible image, Pluma records it, marked as a sample. They're mostly training crawlers: the ones that put you in answers usually fetch only the page.

**For the full count**, your site's server tells Pluma each time a known AI crawler asks for a page. It sends the user agent, the path and the response code to:

`POST https://pluma.so/collect/bots`

with the header `Authorization: Bearer <a key of the site>`. A **delivery** key is enough (create one in **Settings → Keys**); keep it in an environment variable called `PLUMA_KEY`, never in the page. The body is `{"ua": "…", "path": "/menu", "status": 200}`, or `{"visits": [ … ]}` with up to 100. Pluma answers `204` and ignores anything that isn't a known crawler.

The report goes out after the page is sent, so it doesn't slow your site down. Pick your host:

### Netlify

`netlify/edge-functions/pluma-bots.ts`:

```ts
import type { Config, Context } from "@netlify/edge-functions";

const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;

export default async (request: Request, context: Context) => {
  const response = await context.next();
  const ua = request.headers.get("user-agent") ?? "";
  if (BOTS.test(ua)) {
    context.waitUntil(fetch("https://pluma.so/collect/bots", {
      method: "POST",
      headers: { Authorization: `Bearer ${Netlify.env.get("PLUMA_KEY")}`, "Content-Type": "application/json" },
      body: JSON.stringify({ ua, path: new URL(request.url).pathname, status: response.status }),
    }).catch(() => {}));
  }
  return response;
};

export const config: Config = { path: "/*", excludedPath: ["/_astro/*", "/*.css", "/*.js", "/*.png", "/*.jpg", "/*.svg", "/*.webp", "/*.ico"] };
```

Add `PLUMA_KEY` in **Site configuration → Environment variables**.

### Vercel

`middleware.ts` at the root of the project (works with any framework on Vercel):

```ts
import { waitUntil } from "@vercel/functions";

const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;

export default function middleware(request: Request) {
  const ua = request.headers.get("user-agent") ?? "";
  if (BOTS.test(ua)) {
    waitUntil(fetch("https://pluma.so/collect/bots", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.PLUMA_KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ ua, path: new URL(request.url).pathname }),
    }).catch(() => {}));
  }
}

export const config = { matcher: "/((?!_next|_astro|.*\\.(?:css|js|png|jpg|svg|webp|ico)$).*)" };
```

Vercel middleware runs before the page, so it doesn't know the response code; Pluma stores it as empty.

### Cloudflare Pages

`functions/_middleware.ts`:

```ts
const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;

export const onRequest: PagesFunction<{ PLUMA_KEY: string }> = async (context) => {
  const response = await context.next();
  const ua = context.request.headers.get("user-agent") ?? "";
  if (BOTS.test(ua)) {
    context.waitUntil(fetch("https://pluma.so/collect/bots", {
      method: "POST",
      headers: { Authorization: `Bearer ${context.env.PLUMA_KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ ua, path: new URL(context.request.url).pathname, status: response.status }),
    }).catch(() => {}));
  }
  return response;
};
```

### Your own server

Any server works the same way: after answering a request whose user agent matches the list above, `POST` the visit to `https://pluma.so/collect/bots` without waiting for the answer.

## What you see

**Visibility** has three views, for the last 7, 30 or 90 days:

- **Traffic:** visitors, page views, and how many came **from AI assistants** (and which one). Then where they came from (AI assistants, search engines, social, campaigns, email, direct, other sites), top pages, the sites that sent them, countries, cities, UTM campaigns and devices.
- **AI crawlers:** visits by crawler, who runs it and why: **search** (puts you in answers), **user** (reads a page for a person, right now) or **training** (collects data to train models). And which pages they read for answers.
- **Readiness:** whether assistants can read the site at all ([AI visibility](ai-visibility.md)).

How a visit is classified:

- **AI assistants:** the referrer is chatgpt.com, perplexity.ai, gemini.google.com, claude.ai, copilot.microsoft.com and others, or the link has `utm_source=chatgpt.com` (ChatGPT adds it to the links it shows).
- **Campaigns:** the link has `utm_source`.
- **Direct:** no referrer and no UTM.

## What it stores

- **No cookies** and nothing in the visitor's browser, so no cookie banner is needed for it.
- **No IP addresses.** The IP is used once, when the visit arrives: to look up country and city in a database on Pluma's own server ([IP Geolocation by DB-IP](https://db-ip.com), CC BY 4.0), and to make the visitor code. Then it's dropped.
- The **visitor code** is a hash of the site, IP and browser with a key that changes every day. A person counts once a day per site; tomorrow they're a new code, and it can't be turned back into an IP.
- Visits are kept **13 months**, then deleted.
- You're the controller of your visitors' data and Pluma processes it for you: mention it in your site's privacy policy. See the [Pluma privacy policy](https://pluma.so/privacy).

---

<!-- docs/guides/ai-answers.md -->

# AI answers

What do ChatGPT and Gemini say when a customer asks for what you sell? **AI answers**, in **Visibility**, asks them every week and tells you, for each:

- in how many answers **your business is mentioned**, and in what position when the answer is a list;
- in how many it **cites your site** as a source;
- which **competitors** show up in the answers (only the ones the answers name: Pluma doesn't ask about competitors on purpose);
- anything it says about you that **contradicts your own content** in Pluma (hours, prices, address, products).

Your agent reads the same with `get_answers` and changes the questions with `update_questions` ([MCP tools](../mcp/tools.md)); the API has them at `GET /api/v1/spaces/:space_id/answers` and `PATCH /api/v1/spaces/:space_id/questions` ([Management API](../api/management.md#ai-answers)).

## Nothing to set up

You don't configure anything. As soon as the site has its **live address** and some **published content**, Pluma prepares the first report by itself: it guesses where your customers are (from your domain, like `.gt`, or from where the site was created), picks the questions from real searches, asks ChatGPT and Gemini, and reads your site. It takes a few minutes; the site's **Overview** shows "preparing" meanwhile. After that it refreshes every Monday.

**Overview** is the first thing you see: in how many answers each assistant mentions you, who they recommend instead, what they get wrong about you, where they get their facts, your visitors from AI and whether AI can read your site.

There's nothing to adjust in the dashboard: no questions to write and no place to pick. Pluma picks them from what people really search and refreshes them on its own. An agent can still replace them with `update_questions`.

## Your questions

Up to 30, the way a customer would ask, **without your business's name**. They start from what people actually look for, not from what your site says:

1. **Your own searches first.** If the site has [Search Console or Bing](search-console.md) connected, Pluma starts from what people searched before they found you. When they connect for the first time, Pluma picks the questions again from them (unless an agent set its own with `update_questions`); the next Monday run asks the new ones.
2. **Real Google searches for your category.** From your content Pluma takes only your **category and place** ("pastelería", "milhojas" + "zona 14", "guatemala") and gets the Google searches that contain them in your country, with how many times a month people search each one ([DataForSEO](https://dataforseo.com)). Most searched first.
3. **Google's autocomplete** adds the local and product searches the first two miss ("milhojas zona 14 a domicilio").
4. It leaves out searches that name a business (yours or others'), and turns the rest into questions a person would ask ChatGPT. Each question keeps the search it came from, its source (`search_console` or `google`), its Google volume (`search_volume`) and how often people ask it to AI assistants (`ai_volume`).

If none of these give enough, Pluma writes them from your content instead and marks them "suggested".

They're asked from your customers' **place** (country and city), because assistants answer differently in Guatemala City than in Madrid. Pluma guesses it from your domain (`.gt`), from where the site was created, or from your content.

## How it's measured

1. Every Monday, Pluma asks each question to four assistants through [DataForSEO](https://dataforseo.com): **ChatGPT and Gemini as they answer in their apps**, and **Claude and Perplexity through their APIs with web search on** (marked "API": close to their apps, not identical). All from your country. It keeps each answer, its sources, the brands it names and the searches the assistant ran behind the scenes.
2. **Competitors** are the businesses the answer names, minus yours. **Cited** means one of the answer's sources is your live site's domain.
3. A smaller model reads each answer and says whether you're mentioned, where, the tone, and which statements contradict your published content.
4. Each question shows how many times a month people ask AI assistants that search (AI search volume), so the list is ordered by what matters most.

Everything is stored in Pluma: the page, your agent (`get_answers`) and the API read the stored results, and the weekly run refreshes them. Without DataForSEO configured, Pluma asks ChatGPT through OpenAI's API instead.

Things to know:

- **Answers change** from one day to the next. Read the trend over weeks, not one run.
- One call per question per week. Each run shows what it cost.
- What's sent: to DataForSEO, only the questions and your country; to OpenAI, the answers to read, your business's name and your published content. Nothing about your visitors. See the [Pluma privacy policy](https://pluma.so/privacy).

---

<!-- docs/guides/search-console.md -->

# Search Console and Bing

See what people searched on **Google** and **Bing** before they saw your site or clicked it: the top searches and pages, with clicks, impressions, CTR and average position, for the last 28 days. The data comes from **Google Search Console** and **Bing Webmaster Tools**, connected with the site owner's own accounts.

You see it in **Settings → Integrations** in your site's dashboard, your agent sees it with `get_search_queries` ([MCP tools](../mcp/tools.md)), and the API has it at `GET /api/v1/spaces/:space_id/search_queries` ([Management API](../api/management.md#search-queries)).

- **Owners and admins** connect and disconnect. **Everyone on the site** sees the data and can press **Sync now**. Editors and authors see "Ask an owner to connect it".
- After you connect, **you pick the property** (Google) or site (Bing) that is this site. The one with your live URL's domain comes selected, but any of the account's works: a preview domain too. **Change property** on the card switches it later (for example, when the site moves to its own domain); Pluma fetches the data again for the new one.
- Pluma syncs the last 28 days every day, by itself. **Sync now** fetches them again, once every 10 minutes at most.

## Google

1. Your site has to be in [Google Search Console](https://search.google.com/search-console), verified, with the same domain as its live URL. A **domain property** (`sc-domain:example.com`) or a **URL-prefix** one (`https://example.com/`) both work; if you have both, Pluma uses the domain one.
2. In **Settings → Integrations**, press **Connect Google** and sign in with the Google account that has the site.
3. Google asks if Pluma may **view Search Console data for your verified sites**. Allow it. That's read-only: Pluma can't change anything in Search Console.
4. Back in Pluma, pick the property for this site and press **Use this property**. The first sync happens right away.

If the Google card says **Not available yet**, this Pluma server doesn't have Google sign-in set up. Bing works without it.

To disconnect, press **Disconnect** on the Google card. Pluma tells Google to forget the access and deletes the Google data it saved for the site. You can also remove Pluma's access from your [Google Account](https://myaccount.google.com/permissions).

## Bing

1. Your site has to be in [Bing Webmaster Tools](https://www.bing.com/webmasters), verified. If it's already in Google Search Console, Bing can import it in a minute.
2. In Bing Webmaster Tools, open **Settings** (the gear icon) → **API access** → **API key**, and generate a key.
3. In **Settings → Integrations**, paste the key in the Bing card, press **Connect Bing**, and pick the site.

The key reads every site in that Bing account. Pluma only uses it for the site whose domain matches. To stop it, press **Disconnect** (Pluma deletes the key and the Bing data), and you can also generate a new key in Bing to replace it.

Bing updates its numbers once a week and gives them by week, so the Bing view moves in weekly steps.

## What you see

For the engine you pick (Google or Bing, when both are connected):

| Number | What it means |
| --- | --- |
| **Clicks** | Times someone clicked through to your site from the results |
| **Impressions** | Times your site showed up in the results someone saw |
| **CTR** | Clicks per impression |
| **Position** | Your average place in the results, weighted by impressions. 1 is the top |

Then the **top searches** (what people typed) and the **pages that got clicks**. Google leaves out very rare searches to protect people's privacy, so the searches add up to less than the total.

## What isn't here

**No AI data.** Google shows AI Overviews and AI Mode results inside Search Console, and Bing has an AI Performance report for Copilot, but **neither is in their APIs**: only in their own websites. So Pluma can't show which AI answers cited your site, from Google or Bing. What you see here is classic search.

To see how AI assistants find your site, use the other views: [Analytics](analytics.md) counts visitors sent by ChatGPT, Perplexity, Gemini and others, and which AI crawlers read your pages; [AI visibility](ai-visibility.md) checks what assistants need to read your site.

## What Pluma stores

- **The connection:** the engine, the property or site it matches, who connected it and when it last synced. **Google's refresh token and the Bing API key are encrypted** in the database, never shown again, never sent to your agent or the API, and never written to the logs.
- **The search data:** for each day (Google) or week (Bing), each search and each page with its clicks, impressions, CTR and position. No data about the people who searched: Google and Bing only give totals.
- It's kept **13 months**, then deleted every day. Disconnecting an engine deletes its data and its key or token right away; deleting the site deletes everything.
- Pluma uses it **only to show it to the site's team and their agents**: not for ads, not shared, not used to train AI models. See the [privacy policy](https://pluma.so/privacy).

---

<!-- docs/guides/site-preview.md -->

# Site preview

Each site in **Sites** shows a picture of its live home page, so you recognize it at a glance.

- It needs the site's live URL (**Settings → General**). Until the first picture is ready, the card says "Preview on its way".
- **It works wherever the site is hosted** (Netlify, Vercel, Cloudflare, your own server): Pluma doesn't wait for a message from the host. After content changes (publishing, unpublishing, deleting), it looks at the live home page every few minutes for up to 20 minutes, and takes a new picture as soon as the page changes, which means the new build is live.
- It also looks once a day, in case the site changed outside Pluma.
- The picture is the first screen of the home page on a laptop (1280 × 800). Pluma takes it from the public internet, like a visitor: nothing is installed on your site.

---

<!-- docs/errors/check_unavailable.md -->

# `check_unavailable`

**HTTP 422** · The readiness check couldn't run.

## Why it happens

- The site has no live URL yet, so Pluma doesn't know where to look.
- The live URL isn't public `https` (Pluma only reads the public internet).
- The last check was less than a minute ago.

`details.reason` says which one.

## How to fix it

Set the live URL with [`update_site`](../mcp/tools.md) or in **Settings → General**, then run the check again. If you just ran one, wait a minute. See [AI visibility](../guides/ai-visibility.md).

## Example

```json
{
  "error": {
    "code": "check_unavailable",
    "message": "The readiness check couldn't run.",
    "fix": "details.reason says why. Usually the site's live URL isn't set (set it with update_site or in Settings → General), or the last check was less than a minute ago.",
    "doc_url": "https://pluma.so/docs/errors/check_unavailable",
    "details": { "reason": "Set the site's live URL first (Settings → General)." }
  }
}
```

---

<!-- docs/errors/invalid_body.md -->

# `invalid_body`

**HTTP 400** · The request body is not valid JSON.

## Why it happens

The JSON is malformed, or an object the endpoint expects is missing (for example fields).

## How to fix it

Check your quotes and commas, and send the header Content-Type: application/json.

## Example

```json
{
  "error": {
    "code": "invalid_body",
    "message": "The request body is not valid JSON.",
    "fix": "Check your quotes and commas, and send the header Content-Type: application/json.",
    "doc_url": "https://pluma.so/docs/errors/invalid_body"
  }
}
```

---

<!-- docs/errors/invalid_key.md -->

# `invalid_key`

**HTTP 401** · The key does not exist or was revoked.

## Why it happens

It happens when the key has a typo, was revoked, or belongs to another environment.

## How to fix it

Check that you copied all of it (it starts with `pluma_`). If it was revoked, create a new one in **Settings → Keys**.

## Example

```json
{
  "error": {
    "code": "invalid_key",
    "message": "The key does not exist or was revoked.",
    "fix": "Check that you copied all of it (it starts with pluma_). If it was revoked, create a new one in Keys.",
    "doc_url": "https://pluma.so/docs/errors/invalid_key"
  }
}
```

---

<!-- docs/errors/invalid_parameter.md -->

# `invalid_parameter`

**HTTP 400** · A parameter is not valid.

## Why it happens

For example `limit=5000` (the maximum is 1000), `order=` with a field that does not exist, or `rich_text=pdf`.

## How to fix it

The `details` field says which one and what values it accepts.

## Example

```json
{
  "error": {
    "code": "invalid_parameter",
    "message": "A parameter is not valid.",
    "fix": "The details field says which one and what values it accepts.",
    "doc_url": "https://pluma.so/docs/errors/invalid_parameter"
  }
}
```

---

<!-- docs/errors/invite_expired.md -->

# `invite_expired`

**HTTP 410** · This invite link has expired.

## Why it happens

More than 24 hours have passed since the link was created.

## How to fix it

Ask the site owner for a new link: in the dashboard, Assistants → Invite agent. Links last 24 hours.

## Example

```json
{
  "error": {
    "code": "invite_expired",
    "message": "This invite link has expired.",
    "fix": "Ask the site owner for a new link: in the dashboard, Assistants → Invite agent. Links last 24 hours.",
    "doc_url": "https://pluma.so/docs/errors/invite_expired"
  }
}
```

---

<!-- docs/errors/invite_used.md -->

# `invite_used`

**HTTP 410** · This invite link has already been used.

## Why it happens

Invite links work only once. Someone (probably you) already redeemed it.

## How to fix it

Ask the site owner for a new link: in the dashboard, Assistants → Invite agent. Each link works only once.

## Example

```json
{
  "error": {
    "code": "invite_used",
    "message": "This invite link has already been used.",
    "fix": "Ask the site owner for a new link: in the dashboard, Assistants → Invite agent. Each link works only once.",
    "doc_url": "https://pluma.so/docs/errors/invite_used"
  }
}
```

---

<!-- docs/errors/key_cannot.md -->

# `key_cannot`

**HTTP 403** · This key cannot do that.

## Why it happens

Each key type has fixed permissions: `delivery` reads published content, `preview` reads drafts, `management` can also write.

An agent can also do only what the person who connected it can: their role, and, if an owner limited them, only [some content types or entries](../guides/team.md#limit-what-someone-can-edit). Then the message says what's allowed, like `You can only change entries of Event (event).`

## How to fix it

To write you need a `management` key. To read drafts, a `preview` key. See the key types in the API docs. If the message says what you *can* change, work inside that, or ask an owner or admin to widen the person's access in Team.

## Example

```json
{
  "error": {
    "code": "key_cannot",
    "message": "This key cannot do that.",
    "fix": "To write you need a management key. To read drafts, a preview key. See the key types in the API docs.",
    "doc_url": "https://pluma.so/docs/errors/key_cannot"
  }
}
```

---

<!-- docs/errors/missing_key.md -->

# `missing_key`

**HTTP 401** · The key is missing.

## Why it happens

The request has no `Authorization` header, or it does not start with `Bearer `.

## How to fix it

Send the header `Authorization: Bearer <your key>`. You create keys in the site dashboard, under **Keys**.

## Example

```json
{
  "error": {
    "code": "missing_key",
    "message": "The key is missing.",
    "fix": "Send the header Authorization: Bearer <your key>. You create keys in the site dashboard, under Settings → Keys.",
    "doc_url": "https://pluma.so/docs/errors/missing_key"
  }
}
```

---

<!-- docs/errors/not_found.md -->

# `not_found`

**HTTP 404** · It does not exist.

## Why it happens

The object does not exist in this site. With a `delivery` key, a draft or archived entry also returns 404.

## How to fix it

Check the ID or the API ID. To list what exists, request the collection (`/entries`, `/content_types`, `/assets`).

## Example

```json
{
  "error": {
    "code": "not_found",
    "message": "It does not exist.",
    "fix": "Check the ID or the API ID. To list what exists, request the collection (/entries, /content_types, /assets).",
    "doc_url": "https://pluma.so/docs/errors/not_found"
  }
}
```

---

<!-- docs/errors/rate_limited.md -->

# `rate_limited`

**HTTP 429** · Too many requests with this token.

## Why it happens

Each token (key or agent) can make up to **600 requests per minute**, counting the API and MCP together. It happens, for example, when you import hundreds of entries without a pause.

## How to fix it

Wait the number of seconds in the `Retry-After` header (they also come in `details.retry_after`) and retry. For large imports, check `X-RateLimit-Remaining` on each response and slow down before it reaches zero.

## Example

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests with this token.",
    "fix": "Wait the number of seconds in Retry-After and retry. The limit is 600 requests per minute per token.",
    "doc_url": "https://pluma.so/docs/errors/rate_limited",
    "details": { "retry_after": 23 }
  }
}
```

---

<!-- docs/errors/translation_unavailable.md -->

# `translation_unavailable`

**HTTP 503** · The translation couldn't run.

## Why it happens

- Pluma has no AI provider key on this server, so [Translate with AI](../guides/languages.md#translate-with-ai) is off.
- The AI provider didn't answer, answered with an error, or sent back something unreadable.

`details.reason` says which one. Nothing was saved: the entry has the same versions it had.

## How to fix it

Try again in a minute. If it keeps failing, write the translation yourself with `PATCH /api/v1/spaces/:space_id/entries/:id?locale=en`, or `update_entry` with `locale`. See [Languages](../guides/languages.md).

## Example

```json
{
  "error": {
    "code": "translation_unavailable",
    "message": "The translation couldn't run.",
    "fix": "details.reason says why: Pluma has no AI provider key, or the provider didn't answer. Nothing was saved; try again in a minute.",
    "doc_url": "https://pluma.so/docs/errors/translation_unavailable",
    "details": { "reason": "OpenAI answered 429: You exceeded your current quota" }
  }
}
```

---

<!-- docs/errors/validation_failed.md -->

# `validation_failed`

**HTTP 422** · The data did not pass validation.

## Why it happens

A required field left empty when publishing, a number that is not a number, a duplicate slug, an invalid API ID, a reference to a type the field does not accept, or an image with no alt text.

## How to fix it

details.fields says which field and why. Fix it and retry; with dry_run=true you can test without saving.

## Example

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The data did not pass validation.",
    "fix": "details.fields says which field and why. Fix it and retry; with dry_run=true you can test without saving.",
    "doc_url": "https://pluma.so/docs/errors/validation_failed"
  }
}
```

---

<!-- docs/errors/version_conflict.md -->

# `version_conflict`

**HTTP 409** · Someone saved another version in the meantime.

## Why it happens

You sent version: N but the entry is already at a higher version. Pluma does not overwrite other people's changes.

## How to fix it

Read the entry again, apply your change on top of the new version, and send that version.

## Example

```json
{
  "error": {
    "code": "version_conflict",
    "message": "Someone saved another version in the meantime.",
    "fix": "Read the entry again, apply your change on top of the new version, and send that version.",
    "doc_url": "https://pluma.so/docs/errors/version_conflict"
  }
}
```

---

<!-- docs/errors/wrong_space.md -->

# `wrong_space`

**HTTP 403** · This key belongs to another site.

## Why it happens

The URL asks for `/spaces/<slug>` but the key was created in another site.

## How to fix it

Use the key of the site in the URL. Each key works for one site only.

## Example

```json
{
  "error": {
    "code": "wrong_space",
    "message": "This key belongs to another site.",
    "fix": "Use the key of the site in the URL. Each key works for one site only.",
    "doc_url": "https://pluma.so/docs/errors/wrong_space"
  }
}
```

---

<!-- docs/index.md -->

# Pluma docs

Pluma is a general-purpose headless CMS, made so an AI agent can use it on its own. These docs are the contract: **no feature exists until its page exists**.

If you are an agent, start with [`/llms.txt`](/llms.txt): it's the index of everything in plain markdown. [`/llms-full.txt`](/llms-full.txt) has everything in a single file.

## Get started

- [What Pluma is](start/what-is-pluma.md)
- [Concepts](start/concepts.md)
- [First 5 minutes](start/first-5-minutes.md)

## The 5 steps of every site

1. [Create the site](guides/create-a-site.md)
2. [Invite your agent](guides/invite-your-agent.md)
3. [Connect the MCP](guides/connect-mcp.md)
4. [Invite your team](guides/team.md)
5. [Set up the deploy](guides/deploy-netlify.md)

## Your content

- [Model your content](guides/content-model.md): content types, fields and their types
- [Create and publish content](guides/entries.md): draft, published, versions
- [Composable pages](guides/composable-pages.md): new pages by chat, with sections the site already knows how to draw
- [Staging and previews](guides/staging.md): see drafts on your real site before you publish
- [Images and files](guides/files.md): upload, use in entries and in rich text

## Reference

- [API: conventions](api/index.md) · [Delivery](api/delivery.md) · [Preview](api/preview.md) · [Management](api/management.md)
- [Agents](api/agents.md): invite, token, your site's docs, keys
- [Webhooks](api/webhooks.md): deploy hooks
- [MCP](mcp/index.md) · [Tools](mcp/tools.md)
- [Rich text](reference/rich-text.md): the JSON block format
- [Errors](errors/index.md)
- Frameworks: [Astro](frameworks/astro.md) (the pilot's pattern) · [plain fetch](frameworks/fetch.md) for anything else
