# Minion - documentation for language models Minion is an AI agent workspace: it manages agents ("minions") that run on your own hosts, and it carries the workspace tools those agents and their humans share - notes, forms, calendar, drive, accounting, and a headless CMS. This file contains only the pages needed to integrate the CMS. Each page begins with its canonical URL. Full documentation: https://docs.minionworkspace.com/llms-full.txt Japanese: https://docs.minionworkspace.com/ja/llms-cms.txt ## Rules that are easy to get wrong Read these before writing code. They are the points where a reasonable-looking implementation is the wrong one. 1. **CMS content is read as static JSON at build time, never per request.** Published entries are written to public object storage as files. Fetch those files while building the site. Do not fetch them in SSR or ISR, and do not call the read API (`/api/public/cms/...`) to render pages - that endpoint exists for previewing drafts and for writes. 2. **If fetching the published JSON fails, fail the build.** Do not catch the error and render an empty list: that ships a site with all of its content missing. 3. **Published CMS JSON is world-readable.** There is no authentication on it. The API key protects drafts and writes only. Never present it as access control. 4. **Field API IDs are the JSON keys and cannot be renamed after publishing.** Labels can be renamed freely, so never derive keys from labels. 5. **Ordering and reference expansion are already applied server-side.** Do not re-sort the list, and expect referenced entries as objects expanded one level deep. --- # CMS integration Source: https://docs.minionworkspace.com/guides/cms-integration/ Summary: Everything needed to read a Minion CMS site from a static front end, on one page. This page is deliberately self-contained. It repeats things that also appear in the [CMS guide](https://docs.minionworkspace.com/guides/cms/) so that it can be handed to someone — or to a coding assistant — as the only thing they need to read to wire a front end to a site. A plain-text version for language models is at [`/llms-cms.txt`](https://docs.minionworkspace.com/llms-cms.txt). ## The shape of the integration, in three sentences Publishing writes plain JSON files to public object storage. Your site reads those files **while it builds**, and renders them however you like. When content is published, a webhook tells your host to build again. Nothing about serving the site involves us. That is what makes traffic free and our outages survivable — and it is also the constraint that decides every question below. ## What you need before you start Three values, all from the site's **Delivery** tab in Minion: | Value | Looks like | Where | |---|---|---| | Delivery base URL | `https://.../` | Delivery tab, top. Copy the real one — do not guess the host | | Site ID | a UUID | Same tab, and in the dashboard URL `/cms/{siteId}` | | Content type API ID | `news`, `posts` | The name you gave the content type. It is the JSON filename | Keep the base URL and site ID in environment variables. They do not change when a site is transferred to another workspace, so a handover needs no code change. ## The URLs ``` {base}/{siteId}/api/index.json every content type on the site {base}/{siteId}/api/{apiId}.json a list type's entries, or an object type's single entry {base}/{siteId}/api/{apiId}/{entryId}.json one entry, by id {base}/{siteId}/api/{apiId}/{slug}.json the same entry, by slug ``` All four are plain files. `GET` only, no headers, no authentication. ## The response shapes **A list type** is a page-shaped response, so the same code works against the read API: ```json { "contents": [ /* entries */ ], "totalCount": 12, "offset": 0, "limit": 12 } ``` **An object type** and **a single entry file** are the entry itself, not wrapped. **Every entry** carries the same system keys, followed by your fields keyed on their API IDs: ```json { "id": "1d5f21ef-…", "slug": "hello-world", "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z", "publishedAt": "2026-01-01T00:00:00.000Z", "sortOrder": 0, "title": "Hello world", "body": "# Hello\n…" } ``` `slug` is `null` when the entry has none — fall back to `id` when building a URL. ## Field types, and what they look like in JSON | Field type | JSON | |---|---| | Text, Text area | `"…"` | | Rich text | `"…"` — **Markdown**, not HTML | | Number | `0` | | Boolean | `true` | | Date | `"2026-01-01"` | | Select | `"news"` — the option's **label**, not an internal value | | Image / File | an object, below | | Content reference | `{ "id": …, "slug": … }` — the referenced entry, expanded one level | | Repeat | `[ { … } ]` — an array of objects | | Embed URL | `"https://…"` — a YouTube or Vimeo URL. Video is not hosted here | A field marked **multiple** becomes an array of the same shape. A media field expands to the whole file: ```json { "id": "…", "url": "https://…", "object_key": "…", "filename": "cover.jpg", "mime_type": "image/jpeg", "byte_size": 148213, "width": 1200, "height": 630, "alt": "…", "variants": [{ "width": 800, "format": "webp", "url": "https://…" }] } ``` `variants` are resized copies generated at upload time. Map them into `srcset` rather than serving the original to every device. ## What is guaranteed You do not need to defend against these in your templates: - **Order is already applied.** Entries come out in the order shown in the editor — manual ordering first, then newest published. Do not re-sort. - **References are expanded one level deep.** A referenced entry is an object. If it is unpublished or deeper than one level you get `{ "id": "…" }` rather than `null`, so reading `ref.id` never throws. - **Drafts are never present.** Unpublished entries are not written to these files at all. - **Individual files exist for every listed entry.** They are written before the list file, so an entry that appears in `{apiId}.json` always has its own file. - **Media URLs are absolute** and safe to put straight into `src`. ## Complete examples ### Astro ```astro --- // src/pages/news/index.astro const CMS_BASE = process.env.CMS_BASE const SITE_ID = process.env.CMS_SITE_ID const API_ID = 'news' const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/${API_ID}.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} ${API_ID}.json`) const { contents } = await res.json() --- ``` ```astro --- // src/pages/news/[slug].astro import { marked } from 'marked' export async function getStaticPaths() { // getStaticPaths runs on its own at build time and cannot see variables declared // elsewhere in this frontmatter, so read the environment here. const CMS_BASE = process.env.CMS_BASE const SITE_ID = process.env.CMS_SITE_ID const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) const { contents } = await res.json() // The list already contains every field, so pass the entry through as a prop. // Fetching each entry's own file here would add one request per article for nothing. return contents.map((entry) => ({ params: { slug: entry.slug ?? entry.id }, props: { entry }, })) } const { entry } = Astro.props const body = marked.parse(entry.body ?? '', { async: false }) const cover = entry.cover ?? null ---

{entry.title}

{cover && ( `${v.url} ${v.width}w`).join(', ')} alt={cover.alt ?? ''} /> )}
``` `process.env` rather than `import.meta.env`: only the latter's `PUBLIC_`-prefixed values and `.env` file entries are exposed, while CI usually supplies these as real environment variables. ### Next.js (App Router) ```tsx // app/news/page.tsx export const dynamic = 'force-static' const CMS_BASE = process.env.CMS_BASE! const SITE_ID = process.env.CMS_SITE_ID! export default async function NewsIndex() { const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) const { contents } = await res.json() return ( ) } ``` ```tsx // app/news/[slug]/page.tsx export const dynamic = 'force-static' export const dynamicParams = false const CMS_BASE = process.env.CMS_BASE! const SITE_ID = process.env.CMS_SITE_ID! async function list() { const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news.json`) return res.json() } export async function generateStaticParams() { const { contents } = await list() return contents.map((entry: any) => ({ slug: entry.slug ?? entry.id })) } export default async function NewsEntry({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const res = await fetch(`${CMS_BASE}/${SITE_ID}/api/news/${slug}.json`) if (!res.ok) throw new Error(`CMS fetch failed: ${res.status} news/${slug}.json`) const entry = await res.json() return

{entry.title}

} ``` `dynamicParams = false` makes a request for an unknown slug a 404 instead of a runtime render — which is what you want, because a slug that is not in the list does not exist. ### Nuxt ```ts // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { cmsBase: process.env.CMS_BASE, cmsSiteId: process.env.CMS_SITE_ID, }, // Prerender the index and follow its links, so every entry page is generated. nitro: { prerender: { crawlLinks: true, routes: ['/news'] } }, }) ``` ```vue ``` Build with `nuxi generate`. ## Errors **Let a failed fetch fail the build.** Every example above throws rather than returning an empty array, and that is deliberate: if the CMS is briefly unreachable during a build, a caught error deploys a site with all of its articles missing, while a thrown one leaves the previous deployment in place. The same applies to a missing entry file: throw, do not render a blank page. ## Rebuilding when content changes A statically built site does not notice a publish. Create a build hook on your host — Vercel: **Settings → Git → Deploy Hooks**, Netlify: **Site configuration → Build & deploy → Build hooks** — and paste its URL under **Delivery → Webhooks** in Minion. Then press **Test**: a webhook that was never delivered is easiest to notice now, not next week. Events are `entry.published`, `entry.unpublished`, `entry.deleted`, `content_type.updated` and `site.published`. Subscribing to none means all of them. The payload is JSON with an `x-cms-event` header; enable **Sign payload** to also get `x-cms-signature`, the HMAC-SHA256 of the raw body. A failed webhook never rolls back a publish. If your build breaks, the content is still correctly published — fix the build and trigger it again. ## Previewing drafts Drafts are not in the static files, so previewing them is the one case that talks to us directly: ```bash curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \ "https://minionworkspace.com/api/public/cms/{siteId}/news?draftKey=YOUR_DRAFT_KEY" ``` Both keys are on the **Delivery** tab. The endpoint takes a microCMS-compatible subset of query parameters — `limit`, `offset`, `orders`, `fields`, `filters`, `depth`, `q` — and is rate limited to 300 reads per minute per site. Set **Preview URL** under **Settings** to a route on your own site that reads this endpoint, and the editor's **Preview** button opens your page instead of raw JSON. Use it for previews and for updates from CI. Do not use it to render published pages. ## Who can read the published files Anyone with the URL. There is no authentication on published content, and there cannot be — that is what keeps visitor traffic away from us. The site ID is not a secret either: it is part of every media URL, and those end up in the HTML you ship. Anyone who sees a page can read `index.json` and enumerate every content type and every published entry, including ones you never linked. **"Published but not linked yet" is not private** — leave such content unpublished and use **Publish at** instead. The API key protects drafts and writes. It is not access control over what is already published. ## Do not do this | ❌ | ✅ | Why | |---|---|---| | Fetch the read API (`/api/public/cms/…`) to render pages | Fetch the static JSON at build time | Our latency becomes your client's latency, and our outage becomes their outage | | `try { … } catch { return [] }` around the fetch | Throw and fail the build | A caught error deploys a site with no content | | Re-sort `contents` in the template | Render in the order given | Ordering is already applied; re-sorting silently ignores the editor's manual order | | Use `cover.url` for every image | Build `srcset` from `variants` | The original can be 1200px wide and is sent to phones too | | Hardcode the delivery base URL | Read it from an environment variable | The delivery domain is the one thing that may move | | Derive JSON keys from field labels | Use the field's API ID | Labels are renamed freely; API IDs are fixed after publishing | | Treat the API key as access control | Treat published content as public | Static files have no authentication | | Fetch each entry's own file from `getStaticPaths` | Pass the list entry through as a prop | The list already contains every field; per-entry fetches add requests for nothing | --- # CMS Source: https://docs.minionworkspace.com/guides/cms/ Summary: Hand content editing to your client, and read it from the site you built as static JSON CMS lets you hand a website's content over to the person who owns it. Your client edits here; the site you built keeps rendering the pages. It is a *headless* CMS: it has no themes, no templates and no front end of its own. When content is published, Minion writes plain JSON files to public storage. Your site reads those files — usually at build time — and renders them however you like. That split is the whole design: - **Visitors never reach us.** Your site reads static files, so a spike in traffic does not change your bill, and an outage on our side does not take your client's site down. - **Traffic is not metered.** There is no per-request pricing, because the requests do not come to us. - **You can leave.** What is published is already a set of plain JSON files. Export gives you those files, the schema and the images in one ZIP. CMS is experimental and off by default. Ask us to enable it for your workspace if you do not see it in the sidebar. :::tip[Wiring a front end to a site?] [CMS integration](https://docs.minionworkspace.com/guides/cms-integration/) is a single self-contained page with the URL patterns, the JSON shapes, complete Astro / Next.js / Nuxt examples and the mistakes to avoid. It is written to be handed to a developer — or to a coding assistant — on its own. A plain-text version for language models is at `/llms-cms.txt`. ::: ## Connect a site in five minutes **1. Create a site.** One site per project. It is the unit you can later duplicate, hand over, or delete. **2. Create a content type.** A content type is one kind of content — blog posts, news, staff members. Pick its shape: | Kind | Use it for | Published as | |---|---|---| | List | Many entries of the same shape: posts, news, products | An array of entries | | Object | Exactly one entry: site settings, the home page | A single object | You also give it an **API ID** (`posts`, `news`). It becomes the filename of the published JSON, so it cannot be changed later. **3. Define its fields.** Each field has a **label** and an **API ID**. The label is what the editor sees and you can rename it whenever you like. The API ID is the key in the published JSON, so it is fixed once the schema is published. The schema builder shows a live preview of the JSON your fields produce. **4. Write an entry and publish it.** Saving a draft never changes what is live. Publishing pins that version and writes the files. **5. Turn on publishing for the site.** Publishing writes to public storage, so we ask for a payment method on the workspace first. We do not charge you for it — it exists so that free anonymous hosting cannot be used for phishing and malware. **6. Read it from your site.** The **Delivery** tab lists the exact URLs for your content types, with a copy button and a snippet for `fetch`, Next.js and Astro: ```js const res = await fetch('https://…/{siteId}/api/posts.json') const { contents } = await res.json() ``` ## The JSON you get Publishing writes one file per content type, plus one file per entry, plus a manifest: | File | Contains | |---|---| | `/api/index.json` | Every content type on the site, so the front end can discover them | | `/api/{apiId}.json` | A list type's entries, or an object type's single entry | | `/api/{apiId}/{entryId}.json` | One entry, by id | | `/api/{apiId}/{slug}.json` | The same entry, by slug | A list file is a page-shaped response, so the same code works against the read API: ```json { "contents": [ /* entries */ ], "totalCount": 12, "offset": 0, "limit": 12 } ``` Every entry carries the same system keys, followed by your fields keyed on their API IDs: ```json { "id": "…", "slug": "hello-world", "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z", "publishedAt": "2026-01-01T00:00:00.000Z", "sortOrder": 0, "title": "Hello world", "body": "# Hello\n…" } ``` Two things are worth knowing before you write your templates: - **Ordering is already applied.** Entries come out in the order shown on the entry list — the manual ordering (the ↑ / ↓ buttons) first, then newest published. - **References are expanded one level deep.** A referenced entry appears as an object. If it is unpublished or deeper than one level, you get `{ "id": "…" }` instead of `null`, so a template that reads `ref.id` never crashes. ### Who can read these files Anyone with the URL. Published content carries no authentication, and it cannot — that is exactly what keeps your client's traffic away from us, so a busy month does not change your bill and an outage on our side does not take their site down. Two consequences worth planning around: - **The site ID is not a secret.** Media URLs contain it and end up in the HTML you ship, so anyone looking at a page can read `/api/index.json` and from there list every content type and every published entry — including ones your front end never links to. - **"Published but not linked yet" is not private.** If something must stay unreadable until a date, leave it unpublished and set **Publish at** rather than publishing early and linking later. Drafts are unaffected: unpublished entries are never written to these files. **The API key protects the read API — drafts and writes — not published content.** Do not treat it as access control over what you have already published. ## Field types | Type | JSON | Notes | |---|---|---| | Text | `"…"` | Up to 1,000 characters | | Text area | `"…"` | Up to 20,000 characters | | Rich text | `"…"` | Markdown, up to 200,000 characters | | Number | `0` | | | Boolean | `true` | | | Date | `"2026-01-01"` | | | Select | `"news"` | The option's **label**, not its internal value | | Image / File | `{ … }` | A media object, see below | | Content reference | `{ "id": …, "slug": … }` | Another entry, expanded one level | | Repeat | `[ { … } ]` | An array of objects. Nests one level, up to 200 rows | | Embed URL | `"https://…"` | A YouTube or Vimeo URL. We do not host video | Any field marked **multiple** becomes an array of the same shape. Media fields expand to the full file: ```json { "id": "…", "url": "https://…", "object_key": "…", "filename": "cover.jpg", "mime_type": "image/jpeg", "byte_size": 148213, "width": 1200, "height": 630, "alt": "…", "variants": [{ "width": 800, "format": "webp", "url": "https://…" }] } ``` `variants` are the resized copies generated on upload. Use them directly, or use `srcset`. ### Renaming things safely Fields are stored against an immutable internal id, and select options against an immutable value. That means: - **Renaming a label is always safe.** No published site changes. - **Renaming an API ID is not possible** once the schema is published, because it is a key your front end reads. - **Renaming a select option changes the published JSON**, because the label is what gets published. Treat it as an edit to your content. - **Changing a schema never breaks existing entries.** Each entry is interpreted with the schema version it was written against. A field you delete simply stops appearing. ## Previewing drafts Drafts are never written to the static files — that is what makes "editing cannot break the live site" true. To show unpublished content, read the API instead: ```bash curl -H "X-CMS-API-KEY: YOUR_READ_KEY" \ "https://minionworkspace.com/api/public/cms/{siteId}/posts?draftKey=YOUR_DRAFT_KEY" ``` Create the read key under **Delivery → API keys**. The draft key is on the same tab and can be regenerated if it leaks. The key exists because this endpoint can return drafts — it is not what protects published content, which is served as plain files to anyone with the URL. (`X-MICROCMS-API-KEY` is accepted as well, so a front end written against microCMS needs no change here.) Set **Preview URL** under **Settings** to your site's preview route, and the editor's **Preview** button opens your page instead of raw JSON. The read API takes a microCMS-compatible subset of query parameters: | Parameter | Example | | |---|---|---| | `limit` / `offset` | `limit=10&offset=20` | Default 10, max 100 | | `orders` | `orders=-publishedAt,title` | `-` for descending | | `fields` | `fields=id,title` | Trim the response | | `filters` | `filters=category[equals]news[and]title[contains]sale` | `equals`, `not_equals`, `contains`, `begins_with`, `exists`, `not_exists`, joined with `[and]` / `[or]`, evaluated left to right | | `depth` | `depth=2` | How far to expand references, max 3 | | `q` | `q=keyword` | Free text over the entry | Reads are limited to 300 requests per minute per site, writes to 60. :::caution Use the read API for previews and for updates from CI or a minion — not to render pages on every request. If your site calls it in SSR or ISR, our latency becomes your client's latency and our outage becomes their outage. Read the static files at build time instead. ::: ## Rebuilding on publish For a statically built site, publishing content is only half of the job — the site has to rebuild. That is what webhooks are for. Create a build hook on your host (Vercel: **Settings → Git → Deploy Hooks**; Netlify: **Site configuration → Build & deploy → Build hooks**), then paste its URL under **Delivery → Webhooks**. Every publish then triggers a build. Use **Test** right after adding it: a webhook that was never delivered is easiest to notice now, not next week. You can subscribe to specific events, or leave the list empty to receive all of them: `entry.published` · `entry.unpublished` · `entry.deleted` · `content_type.updated` · `site.published` The payload is JSON, delivered with `x-cms-event`: ```json { "event": "entry.published", "site_id": "…", "content_type": "posts", "entry_id": "…", "entry_slug": "hello-world", "occurred_at": "2026-01-01T00:00:00.000Z" } ``` Enable **Sign payload** and we add an `x-cms-signature` header — the HMAC-SHA256 of the raw body with the secret shown once at creation. Verify it if your endpoint does anything more interesting than triggering a build. A failed webhook never rolls back a publish. If your build breaks, the content is still correctly published; fix the build and trigger it again. ### Scheduling An entry can carry **Publish at** and **Unpublish at** times. A scheduler runs every five minutes, so treat the times as "within five minutes of". A scheduled publish goes through the same checks as a manual one: if a required field is empty, the schedule stays and reports the failure rather than quietly dropping it, so fixing the entry is enough to make the next tick publish it. ## Limits, and what happens when you hit them Sites and media storage are counted per workspace: | Plan | Sites | Media storage | |---|---|---| | Free | 2 | 1 GB | | Starter | 5 | 10 GB | | Team | 20 | 50 GB | | Business | 100 | 200 GB | | Enterprise | Unlimited | 1 TB | Going over the quota stops **new uploads only**. Files that are already published keep being served, and publishing keeps working. We never take a live client site down over a quota. ## Who can do what Everyone in the workspace can see a site. Nobody can change it until you say so. | Role | Can | |---|---| | Admin | Everything: publishing, API keys, webhooks, access, deleting the site | | Can edit | Content types and their schema, plus everything a writer can do | | Can write | Entries and media — writing, publishing, uploading | | View only | Read. **The default for every workspace member** | Admin is automatic: workspace owners and admins, plus whoever created the site. The other two are granted per site under **Settings → Access**. Removing a grant puts that person back to view-only. Grant **Can write** to the people who write the content, and **Can edit** only to the people who should be able to change its structure. Deleting a field takes it out of the published JSON the next time that entry is published, which is not something a writer should be able to do by accident. ### Folding away the integration screens Once a site is wired up, the Delivery and Schema tabs stop being useful and start being noise for whoever writes the content. **Settings → Integration mode** folds them away, leaving only the screens needed for writing. Turn it off before you hand a site over, and turn it back on whenever you need to change the wiring. It changes what is shown, not what anyone is allowed to do — the roles above are what actually protect the site. Someone with write or view-only access never sees those tabs either way. ## Duplicating and handing over a site **Duplicate** creates a new site with the same content types, the same schema and the same API IDs — optionally with the entries and images too. The copy always starts unpublished with its entries as drafts, and API keys and webhooks are deliberately not carried over. Use it to try a risky schema change, or to reuse the last project's structure on the next one. **Transfer** moves a site to another workspace: enter their workspace slug, and the site moves once one of their admins accepts. Nothing about the site's public identity changes — the media URLs, the API paths, the API keys and the webhooks are all bound to the site, not to the workspace. A live site keeps running through the handover, and your client's CI needs no new environment variables. Two consequences worth planning around. Per-site access is cleared, because it pointed at members of the old workspace — and since view-only is the default, only the receiving workspace's admins can edit until they grant access to their own people. And if the site is bigger than the receiving plan's quota, the transfer still succeeds: existing files keep being served, and only new uploads are blocked until they upgrade. ## Exporting everything **Export** produces a single ZIP with everything: all content including drafts, the schema definitions in a neutral format, and the media. The `published/` folder holds exactly the JSON that was being served. That matters during a migration: you can drop those files onto any static host and the site keeps working while you move. Media is bundled up to 200 MB. Anything beyond that is listed in the manifest as a URL instead — the cut-off is stated in the README, the manifest and the response headers, never silently applied.