One file for the whole site: forumulate plan and apply

Embed
Preview of the card
<a href="https://forumulate.com/guides/forumulate-yaml-apply"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/db7078e8-fb2c-456e-89f6-c165d653d914/social-card.svg?v=3e7ae33eec8b" alt="One file for the whole site: forumulate plan and apply" width="600" height="315"></a>

Describe your whole presence in one forumulate.yaml — every space and its SEO in each language, the About and Contact pages, your Markdown pages and their translations, redirects, and the site around them — and let two commands make the live site match it:

forumulate plan --diff     # what would change, field by field; writes nothing
forumulate apply           # carry it out; every write is read back
forumulate plan --check    # in CI: exit 1 when the live site has drifted from the repository

A product's help site, ready to copy. product-help.yaml is a complete manifest for the usual SaaS help site: docs, blog, community forum, status page (an HTTP monitor and a heartbeat) and legal pages, each with an SEO description, joined in one site on a subdomain (the walkthrough), with readers signing in through the product's Atlas (readerAuth: atlas). Change the lines marked CHANGE, plan --diff, apply, then forumulate doctor --org <org> reports the sign-in, serving and heartbeat state in one go.

A second apply with nothing changed writes nothing. A page, post, release or topic someone edited outside this file since your last apply shows up in the plan as a conflict, with who edited it and when, and apply refuses to overwrite it until you either pull the edit into your files or pass --force (Studio edits). Nothing you did not declare is ever deleted unless you ask for --prune, and even then only what this file created.

forumulate plan and apply are part of @forumulate/cli from version 0.11.0; conflicts, --force and pull --only need 0.21.0. A CLI behind the current release prints one line saying so, and one older than the API supports exits 6 before writing anything. They send the document to PUT /orgs/{orgId}/manifest, so anything that can make an HTTP request can do the same (see Without the CLI).

Which forumulate.yaml this is

Two different files are called forumulate.yaml, and the version key tells them apart:

The file says

It is

Read by

Reference

version: 2

The site manifest: the whole site, this page.

forumulate plan, forumulate apply

forumulate.yaml, version 2

no version

The publish manifest: which folder goes to which space. Either one space written flat (space:, theme: … at the top) or spaces: as a list of { path, space }.

forumulate publish, forumulate sync

forumulate.yaml (publish manifest)

plan and apply refuse a file without version: 2 and say which command it belongs to. Keep the two in different folders if you use both. The JSON body of PUT /orgs/{orgId}/manifest is the same document as version: 2 (a version: 1 body is the older, smaller form — see The site manifest).

A complete example

One file, with a content/ folder of Markdown beside it and two small files it names (about.yaml, contact-form.yaml), can describe a whole site like acme.com: eight spaces (docs, blog, changelog, status, community, legal, About, Contact) in English and Spanish. The short version:

version: 2
org: acme

spaces:
  - slug: docs
    name: Docs
    type: DOCS
    visibility: PUBLIC
    locales: [{ code: es, label: Español }]
    seo:
      seoTitle: Acme documentation
      description: How to send numbers to Acme and read the explanation back.
    translations:
      es: { name: Documentación, seoTitle: Documentación de Acme }
    redirects:
      - { from: /start, to: /getting-started }   # relative to the space's mount

  - slug: about
    name: About
    type: ABOUT
    visibility: PUBLIC
    locales: [{ code: es, label: Español }]
    frontDoor: { title: About Acme, showFacts: true }
    about: about.yaml              # the About parts, in a file beside this one

  - slug: compare
    name: Comparisons
    type: COMPARE
    visibility: PUBLIC
    compare:
      catalog: compare.yaml        # products, criteria, values, scoring — see Comparison pages
      matrix: matrix.csv           # the matrix as a spreadsheet, laid over the catalog

  - slug: contact
    name: Contact
    type: CONTACT
    visibility: PUBLIC
    locales: [{ code: es, label: Español }]
    seo: { seoTitle: 'Contact Acme: orders, billing and API help' }
    contactForm: contact-form.yaml # fields, words, and translations.es.nextSteps

pages:
  - { dir: content/docs, space: docs }
  - { dir: content/about, space: about }

sites:
  - slug: acme
    name: Acme
    spaces: [docs, about, contact]
    config:
      search: pill
      searchSettings: { typoTolerance: true }
      headerLinks:
        - { label: Docs, href: /docs }
        - { label: Contact, href: /contact, style: button }

org: may also come from --org or FORUMULATE_ORG. The file is found as forumulate.yaml (or .yml, .json) in the current folder, or named on the command line.

Spaces

Each entry in spaces creates the space when it does not exist and updates it when it does. A space's type never changes; declaring another type for an existing slug is refused before anything is written.

Key

What it sets

Through

name, visibility, settings

the space itself (settings merge key by key; an unknown key is refused by name)

PATCH /orgs/{orgId}/spaces/{spaceId}

settings.blog, settings.forum, settings.status

a blog's, a forum's or a status page's own settings

PATCH …/blog/settings, PATCH …/forum/settings, PUT …/status/settings

settings.careers

a careers board's hiring configuration: pipeline, form, ats, indexing (below)

PUT …/careers/pipeline, PATCH …/careers/form, PUT …/careers/ats, PUT …/careers/indexing/indexnow

homeDoc

the space intro, body (Markdown) or doc, and translations.<code> (replaced whole)

PUT …/home-doc

theme

the look (deep merge; a key set to null is removed, and a plan names it once as from → null)

PATCH …/theme

redirects

your redirect rules; from and to are relative to the space's mount, so on a space at /docs the rule /start is /docs/start (platform rename redirects are kept)

PUT …/redirects

collections

a docs space's collections: name, slug, nav, the landing page's description, coverUrl, seo, translations, and parent (a collection's slug; null = top level) (below)

POST and PATCH …/collections

categories, monitors

a forum's categories, a status page's monitors

their own endpoints

locales

the languages the space publishes in

POST …/docs/locales

seo

title, description, title template, site name, social card, verification… (merged per key)

PUT …/seo

description

one sentence about the space (≤ 300 characters); shorthand for seo.description, the same stored value

PUT …/seo

translations

per language: name, description, seoTitle, seoDescription, generated pages' words (merged per language)

PATCH …/spaces/{spaceId} {translations}

preset, piecePreset, frontDoor

the layout and the front door's switches and heading

PUT …/preset

contactForm

a CONTACT space's form: fields, the thank-you, nextSteps, and translations.<code> (nextSteps is a map from the English label to the translated one)

PATCH …/contact/form

about

an ABOUT space's parts: mission, values, team, timeline, company facts…

PUT …/about

compare

a COMPARE space's catalog: products, criteria, values, scoring, freshness; catalog: and matrix: read a YAML file and a CSV sheet (comparison pages)

PUT …/compare/catalog

posts, authors

a BLOG space's posts and author registry (below)

POST …/blog, PUT …/authors/{key}

topics

a FORUM space's staff topics (below)

POST …/forum

openapi

an API reference generated into a collection (below)

POST …/docs/openapi/import

about and contactForm take either the map itself or the name of a YAML or JSON file beside forumulate.yaml; homeDoc takes the map or the name of a Markdown file, whose twins beside it (intro.es.md) are its languages. Every key is checked by the same rules its endpoint uses, so a key the endpoint would refuse is refused in the plan, with nothing written. A settings key that has its own endpoint (seo, frontDoor, theme) is refused under settings and named where it belongs, and so is a key no endpoint knows: settings: {blogg: …} is a 422 naming blogg, not a setting silently not applied. The plan lists each setting it would move, from → to.

A careers board's hiring settings

Roles publish from Markdown (fields in the frontmatter); the configuration around them is settings.careers, so a careers space can be rebuilt from a repository. Each part is optional, planned and applied on its own, and an apply that already matches plans unchanged.

spaces:
  - slug: careers
    type: CAREERS
    settings:
      careers:
        pipeline:
          stages:
            - { key: new, label: New, kind: open }
            - { key: interview, label: Interview, kind: open }
            - { key: hired, label: Hired, kind: hired }
            - { key: rejected, label: Rejected, kind: rejected }
          ratingScale: stars
        form:
          eeo: false
          retentionDays: 365
        ats: { provider: greenhouse, board: acme, syncEnabled: true }
        indexing: { indexNow: true }
  • pipeline is the body of PUT …/careers/pipeline (the stages in order, the rating scale, templates, blind review, the scorecard). Removing a stage that still holds applications needs moveTo, exactly as the endpoint does.

  • form is the body of PATCH …/careers/form (the questions, the question bank, consent, retention, the after-send step); keys left out are kept.

  • ats is the connected board — provider, board, syncEnabled. The API key a keyed provider needs is a secret and stays in the console; it is kept across applies.

  • indexing.indexNow is the same switch as settings.publishPings.indexNow (below), kept for existing files. Google's Indexing API is the organization's own service account (/orgs/{orgId}/google-indexing) and is not declared here.

Who a space tells when it publishes

settings.publishPings is the same on every space type: the body of PUT …/publish-pings, under the same name. Keys left out are kept.

spaces:
  - slug: help
    type: DOCS
    settings:
      publishPings: { indexNow: true }
  • indexNow tells Bing, Yandex, Seznam and Naver when an address changes, and serves the key at <space root>/indexnow.txt. It is accepted on BLOG, CHANGELOG, ABOUT, CAREERS, DOCS, FAQ, COMPARE, CUSTOM, COURSES and LEGAL spaces. On any other type the plan answers 422 naming indexNow, as the API does, with nothing written. What each type pings is in Robots, sitemaps and llms.txt.

  • updateServices, sendWebmentions, acceptWebmentions and autoApproveMentions act on blog and changelog posts.

seo.seoTitle is the <title> of the space's front page; on a Contact or About page the visible heading stays frontDoor.title.

Pages from a Markdown folder

A pages entry with dir reads a folder of Markdown into pages of one space. dir is relative to the folder that holds forumulate.yaml, never to the directory you run the CLI from, so forumulate plan site/forumulate.yaml and cd site && forumulate plan read the same folder. The same holds for every other path in the file (posts.dir, releases, openapi, about), and ignore: globs are relative to dir itself.

content/docs/
  01-getting-started.md      → page getting-started, first
  01-getting-started.es.md   → its Spanish twin          (suffix form)
  guides/02-api-keys.md      → page api-keys, in the "guides" collection
  es/guides/02-api-keys.md   → its Spanish twin          (folder form)
  _drafts/, .hidden          → skipped
  • Twins. Both forms work, in the same tree if you like. A suffix or a top folder counts as a language only when it is one the space declares in locales (or the entry's own locales:), so v1.2.md and a folder called de in a space that does not publish German are ordinary names. A twin keeps its page's address (/docs/es/api-keys) unless its front matter gives its own slug.

  • Front matter. title (else the leading # heading, which is then taken out of the body), slug, seoTitle, seoDescription (or description), canonicalUrl (or canonical), sections (Markdown per slot, for a type with slots), collection, order, preset (the kind of piece, one the space has), parentSlug (the page this one nests under), params (the kind's declared parameters), draft: true to leave a file out, and every page setting forumulate publish reads (icon, hideFromNav, tags, layout, schemaType, …): that is every key PATCH …/pages/{id}/metadata takes, in any casing, and the API's front matter JSON Schema lists them all. api: (or Mintlify's openapi:) names the operation a page documents. kind, customHead and cmsBinding are refused by the manifest (preset: is the page's kind here). A locale: must agree with the language the file name or folder already says. A key nothing reads is an error naming the file.

  • Collections and order. A sub-folder is a collection, created for you in that space when it is missing. order: or a 01- file prefix orders a collection; a collection where no page says its order keeps the order you set in the studio, and new pages are added at the end.

  • A flat folder with category:. A file whose front matter says category: Getting started goes into the collection of that NAME: matched to an existing collection by name or slug (so a space that already has "Getting started" is reused, never doubled), and created, named as you wrote it, when the space has none. Fifty files in one folder, each with a category:, import as a docs tree with no sub-folders. collection: is the same idea by SLUG and wins when both are given; with neither, the sub-folder decides. A file with none of the three lands in the space's default collection, which a space made by this manifest gets silently — the plan does not list + create collections legal/pages for it.

  • Nesting, kind and parameters. In a pages entry these are parent (the slug of a page of the same space and collection, declared in the document or already there; null = top level; left out, the page stays where it is), preset (a kind the space has: PUT …/pages/{id}/shape) and params (judged against that kind's shape, a blank value clears one). A parent is written before its children, a loop or a parent in another collection is refused before anything is written, and order orders a page among the children of its own parent. A translation follows its page's place and kind. Not declarable: versionKey (a manifest describes the current docs version), locale (a language is a translations entry) and onSlugTaken (the slug you name is the page; a taken slug is never suffixed).

  • Links and images. A relative link to another .md file ([x](../usage.md), onboarding/go.md#step-2, an href="…md") becomes a link to that page, anchor kept, in the importer's page-relative form (./usage), which is right under any mount: the page at /docs/go links to /docs/usage. A link to a .md file that no page of the folder came from (missing, ignore:d, a draft) is left as it is and plan prints warning: <file>:<line>: link to <ref> matches no page in this folder. A local image is uploaded once, by its content, and the page points at the upload; changing the picture uploads a new one.

  • ignore:. { dir: docs, space: docs, ignore: ["**/fixtures/**", "drafts/**"] } leaves out every matching path, relative to dir (the same globs as forumulate publish: * within a segment, ** across folders, ?; a pattern without a / matches a name at any depth). plan prints skipped <dir>/<path> ignore: in the manifest. A folder that matches is skipped whole, as one line.

  • Files that are not pages. Only Markdown (.md, .mdx, .markdown) becomes a page. Any other file (catalog.json, mcp-catalog.yaml, a profiler fixture) is skipped, never an error, with skipped <path> (not Markdown); images and attachments are not listed, they are uploaded when a page uses them. Names starting with . or _ and node_modules are skipped silently. A folder whose files are all skipped makes no collection: collections come from the pages that are left.

  • A file with no front matter. The title is the leading # heading (taken out of the body), else the file name made into words (getting-started.md → "Getting Started"). The slug is the file name without its 01- prefix and extension, lower-cased, accents folded, anything else turned into -. The collection is the file's TOP folder (guides/ → "Guides"); deeper folders do not nest, so two files with the same name under one top folder are a refused "slug is also …" error: give one a slug:.

  • ${NAME} in a body. Off by default (a docs page that prints ${HOME} is content). env: true in a file's front matter, on a pages entry with dir (every file under it) or on an inline page turns it on for that page's body and sections. Fenced blocks and inline code are never changed. A name that is set (even to the empty string) is replaced; one that is unset stays as written, plan prints warning: <file>: ${NAME} is not set …, and apply refuses until it is set (--allow-unset-env publishes the literal text).

Only space types that hold pages take pages (docs, legal, About, Contact, custom, FAQ, careers, courses); a blog's posts and a forum's topics are the space's own posts and topics (below).

You can also write a page inline:

pages:
  - space: about
    slug: press
    title: Press
    body: "Logos and photos for the press."
    seoTitle: Acme press kit
    translations:
      es: { title: Prensa, body: "Logotipos y fotos para la prensa." }

Moving a site: redirects

spaces[].redirects is the per-path table, and redirects[/old] in plan --diff lists each rule that moves. A rule is 308 unless it names status. A trailing * carries the rest across. The table applies at every address serving the space, so to keep an old host's paths, claim that host on the same space: rules on a host we do not serve cannot run.

A page can carry its own old addresses in front matter, and the rule goes when the file does:

---
title: Getting started
redirectsFrom: [/docs/getting-started, /start]
---

A DOCS rule whose target is a page the space lacks and pages does not declare fails the plan. forumulate doctor --org <org> follows each exact rule from the live address and reports any that does not end on a 200. To retire an interim *.forumulate.com host, give it redirectTo: https://docs.acme.com (301s every path); a plan warns while it still serves the same pages as an active custom domain. Moved to a new host from one on our edge's sslip.io name? The old row and its routers stay live until you remove them: check the mount is active on the new host, then DELETE the old domain, which drops its router at once. A plan warns about those too.

A legal page's address is what acceptance records and your own footer link to, so keep it. Serve the LEGAL space at the same path (basePath: /legal on the domain) and give each page the slug it has today. If a cookie declaration is embedded in the page, carry it over as a :::customcode block and check the page in a browser before you switch the path. To upload a draft with no one asked to accept it, bind it with required: false; turn that on when it is final, with version: bump.

A CHANGELOG space's releases are matched by version (v1.2.0 and 1.2.0 are one release): a list of {version, title, body, publishedAt, tags, fields, status} entries, or a whole Keep a Changelog file, which the CLI reads and sends as {markdown}:

spaces:
  - { slug: changelog, name: Changelog, type: CHANGELOG, releases: { file: CHANGELOG.md } }

It is the same parser and writer POST …/changelog/import uses, so ### Fixed fills the Fixed slot and the release gets its Type. The difference is the verdict: the import skips a version it has, a manifest updates it, and the plan shows what moved (a console edit to a release's body since the last apply is a conflict the apply refuses without --force). Releases publish without mailing subscribers (status: draft keeps one off the site). One this manifest wrote and no longer declares is deleted only under prune: true.

What's coming next. ## [Unreleased] is left out by default: it has not shipped. releases: { file: CHANGELOG.md, unreleased: true } shows it too, as one entry: version Unreleased, tagged unreleased, pinned above every shipped version. It follows the file on every apply. Once the section is empty (its items moved under a version), the entry is deleted with no prune needed, and the version is created in its place.

From git. releases: {git: {repo, since, include}} drafts the releases from your repository's conventional commits:

spaces:
  - slug: changelog
    name: Changelog
    type: CHANGELOG
    releases: { git: { repo: ., since: v1.0.0, include: [feat, fix] } }

The CLI reads the local git log (the API never sees your repository): one release per tag — the commits after the previous tag up to the tagged one, versioned by the tag and dated by its commit. feat lists under Added, fix under Fixed (perf/refactor Changed, revert Removed, security Security); a ! or BREAKING CHANGE: commit is marked Breaking. repo is relative to forumulate.yaml (default: its directory), since is a tag, a commit or an ISO date, and include defaults to [feat, fix]. Commits after the last tag wait for the next one, and a tag with nothing included is skipped.

forumulate plan lists each new tag as + create release changelog/v1.2.0 with status: draft, for review (from git). apply creates them unpublished; after that each draft is yours: publish it or edit it in the studio, and later applies leave it alone (they only add drafts for new tags). Sent without the CLI, the block needs the drafts list the CLI would have read, or it is a 422.

No tags: cut: day or cut: week (CLI 0.17.0). A repository that deploys every green commit on main has no tags to cut releases at. Use cut instead:

    releases: { git: { cut: week, include: [feat, fix] } }

This drafts one release per day or ISO week (Monday to Sunday), versioned by its first day (2026.11.02) and built from the conventional commits dated in it. Tags are ignored. since defaults to the start of the current day or week, so a regular apply drafts the current period; pass a date to fill in earlier ones. The current period's draft stays open: each apply adds the newer commits to it, but only while it is unpublished and unchanged since the last apply. Once someone edits or publishes it, it is theirs, as with tag drafts. plan lists it as + create release changelog/2026.11.02, then as ~ update when new commits arrive.

A hand-written file and git together (CLI 0.18.0):

    releases:
      file: changelog/CHANGELOG.md
      git: { cut: week, include: [feat, fix, perf], subjects: any, publish: true }

Every release in the file is applied as a {file} release is, and the file wins. If the file has a release with a draft's version, or a release dated inside a draft's day or week, that draft is dropped. Git drafts fill in the periods the file doesn't cover.

  • publish: true publishes a period's release on the first apply after the period closes (midnight UTC at the start of the next day or week). Use publish: {after: 2d} to wait that much longer (hours h or days d). Subscribers are not emailed, as for any manifest release. The plan shows status: draft, for review (from git, publishes 2026-11-09). Each release is published once: if someone unpublishes it later, it stays unpublished. publish needs cut: day or cut: week.

  • subjects: any lists a commit without a type prefix (Atlas 0.19 adoption: …) under Changed instead of dropping it. Merge commits are always left out. The same option is --subjects any on forumulate changelog import --source conventional-commits.

A LEGAL space's legalDocuments bind pages as documents, by page slug:

spaces:
  - slug: legal
    name: Legal
    type: LEGAL
    legalDocuments:
      - { kind: terms, page: terms, required: true, version: bump }

This is POST …/legal/bind (that route asks an API token for admin scope; the manifest unit checks the org.manage_settings capability the manifest already requires, so a write token that can apply the manifest can bind). The page is written first when pages declares it. version: bump gives the document a new edition only if the words members agreed to changed — the rule a publish already follows; the same words keep the version and ask nobody again. Re-pointing a kind at another page always bumps, as the door does.

mode: preview binds a page that may still be an unpublished draft, without asking anybody: readers see nothing, and org members see the prompt it will become at GET /orgs/{org}/legal/acceptance-preview. Changing the entry to mode: live (or removing mode) is the launch — the plan shows mode: preview -> live, and the apply needs the page published:

    legalDocuments:
      - { kind: terms, page: terms-2027, mode: preview }

See Binding a legal document.

Blog posts and forum topics

A BLOG space's posts are matched by slug. Each takes the fields POST …/blog takes (title, excerpt, subtitle, tags, coverUrl, coverAlt, seo, bylines, publishedAt, …), the body as Markdown in body, and status: published (the default) or draft. A manifest publishes without mailing the blog's subscribers — replaying a blog's history must not send one email per post. authors is the space's author registry by key; a byline names one as {author: ana}, and an author declared in the same file is written first.

A post's Markdown can live in a file: body: posts/launch.md reads that file (its front matter fills the post; the entry's own keys win), and posts: { dir: blog } reads every Markdown file in a folder as a post, slug from front matter or the file name (2026-05-25-launch.md is launch, dated 2026-05-25). Front matter is read as forumulate publish reads it: excerpt (or description, summary), date, image/cover, tags, featured, pinned, listed, bylines, status; a key the blog door has no name for is sent as a declared field, and the API refuses one the shape does not declare, by name. author: is a key of this space's authors block when it is one, else a literal name. draft: true leaves a folder's file out. Images the file points at by a relative path are uploaded and the post rewritten to their URLs, as forumulate publish does.

A FORUM space's topics are staff topics — the welcome thread, the rules — matched by slug: title, body (Markdown), category (by name, as categories names them), tags, kind, and pin: true pins it in its category, {pinned: true, global: true} across the forum (banner, until as POST …/pin-advanced takes them). Members' topics are never touched, and never listed as drift.

A staff topic takes the rest of what POST …/forum takes, and each is read back, so a re-plan is empty and a console edit shows as drift with from → to:

  • featuredLink — the link a topic features (null clears it).

  • customization — the topic's own CSS, partial theme, share card and JSON-LD. Keys merge; null clears one key, or every key for the whole object.

  • postAs: org publishes the topic under the organization (its name, its email-branding logo); byline ({name, url, avatarUrl}) or bylines ({name, role, …} or {author: key} from the space's author registry) name who it credits. postAs cannot be combined with the other two; postAs: self puts the posting account back. An unregistered author key is refused at planning, with nothing written. The topic's system lines (pinned, closed, listed, …) that the posting account itself takes — pin in the same declaration, say — read as the byline too, on the page and nowhere in its JSON-LD (system lines are never emitted as comments). A line another moderator takes later stays theirs.

  • poll — the poll POST …/forum takes. A topic's poll cannot be edited in place, so the declaration owns it: a changed declaration replaces the poll, and a poll that already has votes is refused (409) rather than deleted with them. Like the route, closesAt must be in the future whenever the document is read, so drop it once the poll has closed.

  • createdAt — a backdate for a migration (owners and admins). It is applied when the topic is created; the route cannot move it afterwards, so a later change to it is not planned.

A post is compared on every key the blog's update door takes, not only the visible ones: series, access and previewBlocks, socialImageUrl, publishedAt, furtherReading, commentsEnabled, onThisPage, customization, kind and fields, byline/bylines, besides the title, summary, tags, cover and SEO. Each is compared as the door stores it (trimmed; access: public is no access; customization is a merge), so a re-plan after an apply is empty.

Both take translations.<code> — title, excerpt, body, published — for the languages the space declares in locales; each language is its own step (news/launch@es), so a Spanish edit is not a change to the English post.

spaces:
  - slug: news
    type: BLOG
    locales: [{ code: es, label: Español }]
    settings: { blog: { moderateComments: true } }
    authors:
      ana: { name: Ana Ruiz, role: Editor }
    posts:
      - slug: launch
        title: We launched
        body: Today we ship.
        bylines: [{ author: ana }]
        translations:
          es: { title: Lanzamos, body: Hoy lanzamos. }
  - slug: community
    type: FORUM
    categories:
      - { name: Announcements, translations: { es: { name: Anuncios } } }
    topics:
      - slug: welcome
        title: Welcome
        body: Read this first.
        category: Announcements
        pin: { pinned: true, global: true }

A post or topic whose body was edited in the console since your last apply is a conflict: the plan names it, and the apply refuses it without --force. An edit to one of its other fields is drift, written back by the next apply. Translated categories plan unchanged when the stored words match.

An API reference from OpenAPI

openapi on a docs space generates an API reference into a collection — the same key, and the same shape, as in the publish manifest: url (or sourceUrl) or spec (the document, or the name of a YAML or JSON file beside forumulate.yaml), the collection by name (created when the space has none by that name), and the import's own options (mode, slugPrefix, title, pages, onExisting, …). One entry or a list.

  - slug: reference
    type: DOCS
    openapi: { url: https://api.example.com/openapi.json, collection: API reference, mode: per-tag }

The keys of an entry are those of POST …/docs/openapi/import (Importing, "Importing an OpenAPI document"), with the collection by name in place of collectionId:

Key

What it does

url (or sourceUrl) / spec

Exactly one. spec is the document, or a YAML/JSON file name beside forumulate.yaml.

collection

The collection, by name or slug; created when the space has none by that name.

mode

single (one page), per-tag (one page per tag) or per-operation.

slugPrefix

Prefixed to every generated slug (ref → ref-users).

slugStyle, landing, tagPages

The tree's shape (Importing).

landingSlug, landingTitle, landingDescription

The landing page's address, name and meta description.

title

The reference's title; also what the landing slug is derived from.

includeTags, excludeTags

Globs (internal:*) that keep or drop tags.

onExisting

replace (the default here), skip or fail.

replaceCollection

Empty the collection first.

pages

Per tag: SEO words and translations.

servers

A list of {url, description?} that replaces the spec's root servers (below).

untaggedTitle

The page operations without a tag are filed under. Unset: Other endpoints for a new reference; one that already has its Endpoints page keeps it.

autoResync

poll (or true): poll the url and re-import when it changes.

Where the landing lands. landingSlug when set; otherwise slugPrefix + the slug of title (unset: the spec's info.title) — slugPrefix: ref and "Acme API" give ref-acme-api. A NEW reference with landing unset gets no landing page (its tags sit at the collection's top), so set landing: true to have one. Either way /<mount>/<collection-slug> (say /docs/api) 308s to the collection's first page.

servers fixes a spec whose base URL is missing (servers: null) or wrong, without editing the spec you publish: the generated pages, curl samples and "Try it" use it, and the reader may call its origins. It is recorded on the registered spec, so a resync keeps it; drop the key and the next apply goes back to the spec's own.

    openapi:
      url: https://api.example.com/openapi.json
      collection: API reference
      slugPrefix: ref
      landing: true
      servers: [{ url: https://api.example.com/v1, description: Production }]

The plan reads the spec: forumulate plan fetches a url (and reads a spec file) every time, because whether the document changed is what decides the step. A NEW entry plans the import's own dry run, for a collection not made yet: forumulate plan lists every page slug it would write and every one already taken (a collision the apply would refuse). When neither the entry nor the spec has changed since the last apply the step is unchanged — and so it is after a resync (the console's, a deploy hook's or autoResync) has already imported the spec the URL serves now. Otherwise it re-imports, and the plan lists, by slug, the pages that creates (new pages), replaces and deletes (onExisting is replace unless you say otherwise, so a re-run converges on the spec). Prune never deletes a reference. When the spec has operations without a tag, the step also says where they land: untagged: 107 untagged → "Other endpoints".

autoResync: poll (or true; a url entry only) keeps the reference in step with the API between applies, with no hook in your deploy: the spec is polled every ten minutes — with its ETag / Last-Modified, so an unchanged spec costs a 304 — and re-imported when it changes, so a new route appears within ten minutes. Your deploy can also trigger it at once with a signed POST, and your deploy can trigger it with a signed POST. The step's shownOnce carries the trigger secret the first time (see Importing, "Resync when the spec changes").

Interface strings and organization settings

Two top-level keys cover the organization itself:

translationOverrides:          # PUT /orgs/{orgId}/translation-overrides, per string
  es: { nav.home: Inicio }
orgSettings:                   # PUT /orgs/{orgId}/settings/{key}, per setting
  community.allow_uncategorized: false
  community.newuser_max_links: 5

orgSettings keys are the registry's (GET /orgs/{orgId}/settings lists them); an unknown key or a value of the wrong type is refused in the plan. security.require_2fa is refused here — it can lock its writer out, so it is set through its own endpoint. Under prune a string this file wrote is removed and a setting it pinned goes back to its default.

Collections, domains and connectors

A collection takes every key PATCH …/collections/{id} takes. nav merges (a key set to null is removed, so the collection follows the theme again); seo and translations are replaced whole; parent names another collection of the space by slug and may name one this file declares in the same run (the parent is created first). A parent that exists nowhere, or a cycle, is refused in the plan.

collections:
  - { name: Reference, slug: reference, coverUrl: /api/assets/abc }
  - name: Guides
    parent: reference
    description: How-tos for every plan.
    nav: { collapsed: true }
    translations: { es: { name: Guías } }

A domain also takes redirectTo (301 the whole domain to a new home; null clears it) and ipAllowList (the addresses and CIDR blocks that may read it; the list is replaced whole and [] means anywhere), written through PUT …/domains/{id}/redirect and PUT …/domains/{id}/ip-allow-list and read back, so an edit made in the console shows in the plan as from → to. They work on a domain of a site as well as of a space.

A domain's state says how far apply takes it, and it is a minimum: it only ever moves a domain forward, so declaring verified on a domain that is already active leaves it active.

state

What apply does

claimed (default)

Adds the domain and holds it. The step's manual lists what to publish: the ownership TXT record (and the .well-known/forumulate-verify/… file, for a host with no DNS of its own), then the routing record or proxy rule.

verified

Also checks ownership — verify with activate: false — without switching traffic: your own application keeps serving the address.

active

Verifies, then activates: we start answering the address.

The steps run in order, verify → activate. When the ownership proof is not served yet, onUnverified decides what happens:

onUnverified

When the proof is not served yet

hold (default)

The domain stays claimed, the step is applied with waiting: verification not served yet — … (what each proof method saw: the file answered 404, the contents were wrong, no TXT record), and every other unit still applies; apply exits 0. The next apply, once the file is served, takes the domain forward.

fail

The first refusal stops the run there (the rest of the plan is skipped) with the domain and the step named, and apply exits 1.

plan asks the same question without writing anything, so a domain whose file is not served yet already shows waiting: … in the plan. Going live is then one file and two applies at most, with nothing else held back: apply with state: active (the domain is held, everything else lands), deploy the file manual names, apply again (verified + active; a third apply plans no changes). A step that has just activated reports settling: true: routing takes about a minute, and GET …/domains/{id}/setup may answer mode: pending with pendingReason: settling until it has (Custom domains).

One proof per host. Every mount of an organization on the same hostname (acme.com/docs, acme.com/blog, …) shares one verification token, so they all list the same .well-known/forumulate-verify/… file and TXT value, and serving it once verifies every one of them. Once one mount is verified, the others on that host are verified by it. A file or TXT record published for a mount's older per-mount token keeps working. The domain read answers verifiedAt / activatedAt (null until then) beside verified / active.

domains:
  - { hostname: docs.example.com, space: docs, state: active }
  - { hostname: example.com, basePath: /help, space: help, state: verified }

A connector also takes externalRef (your own name for it; a connector holding that name is found by it, even if it was installed by hand) and metadata (flat tags that replace the stored ones, filterable on the list with ?metadata[key]=value), as POST and PATCH …/connectors do. A name another connector already holds is refused in the plan.

Sites

A sites entry sets a site's config — the header and footer (headerLinks, footerColumns, cta, …), its theme, the header search control (search: none | icon | pill) and the site search's settings (searchSettings) — and its member spaces, by slug and in order. config merges like PATCH …/sites/{siteId}; with configMode: replace the stored config becomes exactly yours, like PUT, and the plan lists every key that would be removed before anything is. A site that does not exist yet is created (it needs a name), and a domains entry may name it in the same file.

A site's platform subdomain is subdomain on its entry, not a domains entry: the plan reads + claim subdomain acme (acme.forumulate.com, site help), and subdomain: null releases it. A label that is not a DNS label is refused in the plan, and one already taken is refused when apply claims it. A site that already holds a different subdomain gets a warning: claiming a second one does not release the first.

sites:
  - { slug: help, name: Acme Help, subdomain: acme, spaces: [docs, blog, community] }

The files written for crawlers and models are config keys too: config.robots and config.llms (every option in robots.txt and llms.txt). A page's llms: false front matter keeps it out of every llms file.

Every key

The tables list every key the version-2 schema accepts, by block. A key not in them is refused: the API's schema is strict, so forumulate plan fails with the path and name of the key (status_monitors.0: Unrecognized key(s) in object: 'expectStatuss'), never a clean plan with the key ignored. A test in the API keeps these tables in step with the schema. "Create-time" means the key is read when the thing is created and is not changed on a re-apply.

Environment values

${NAME} is read from the environment anywhere outside pages and posts, so secrets stay in CI: as a whole value (webhookSecret: ${HOOK_SECRET}) or inside a string (url: ${API_URL}/health, name: ${PRODUCT} status). Write $${NAME} for the literal text ${NAME}. In content and code keys (body, markdown, sections, content, customCss, customJs, customHead, css, js, html, script, template) only a whole ${NAME} value is read, because a ${…} inside them is usually the text itself (a JavaScript template literal). A name that is not set stops the run with <path>: ${NAME} is not set in the environment; a name set to the empty string is the empty string (it is not dropped and not an error). Page bodies are separate: see env: true above.

Optional values. ${NAME:-fallback} uses the fallback when NAME is unset or empty, as a shell does. ${NAME:-} is an optional value that reads as the empty string, and ${API_URL:-https://api.example.com}/health reads the default address when the variable is missing.

Leaving a block out. forumulate plan --skip consent (or --skip consent,connectors) drops those top-level blocks before any ${NAME} in them is read. A plan therefore runs while a block's variables are unset, and it prints skipped consent (--skip: not planned, not applied, not pruned). A skipped block is not declared, so --prune leaves what it describes alone. Naming a block the file does not have is an error, and version and org cannot be skipped.

A value the API refuses that still contains ${…} (a manifest sent without the CLI, or a content key above) is answered unexpanded placeholder ${NAME}: set the variable before sending … [Invalid url] — the schema's own reason in brackets.

Top level

Key

Type

Create-time

Effect

version

2

n/a

Required; a tool that does not know the version refuses the file.

org

string

n/a

CLI only, never sent: the organization (or --org, FORUMULATE_ORG).

orgName

string

n/a

CLI only, never sent: the organization may not exist yet — plan previews it as + create org <slug> against an empty org (writing nothing) and apply creates it with this name first. Same as --create-org (which names it after the slug).

spaces

list

no

Spaces to create or update, by slug (table below).

sources

list

no

AI knowledge sources, matched by title.

domains

list

no

Custom domains on a site or a space, with redirectTo, ipAllowList, state (claimed / verified / active, a forward-only minimum) and onUnverified (hold, the default, or fail: what apply does while the proof is not served yet).

reader_auth

list

no

Reader sign-in, merged like PATCH …/reader-auth; org-wide, or one space's override. A signInUrl here must point at a page that reads returnTo and sends the reader back to it: Your own sign-in page.

readerAuth

atlas, or map

no

atlas is a one-word shorthand that expands into an org reader_auth entry. The map {providers, signInUrl, signOutUrl} is the DECLARED form: apply replaces the providers with exactly that list (the plan prints - remove provider external_jwt for a stored one you left out, and removing it signs its readers out) and clears a signInUrl / signOutUrl you left out (~ signInUrl -> null). Providers take the reader_auth fields; approveNewMembers, identityPolicy and outboundSync stay as they are. signInUrl must point at a page that reads returnTo (how); it takes a {returnTo} placeholder and the plan warns when the page may not read it. atlas takes the instance linked to the organization or, failing that, the live atlas_byo provider, keeping its order and settings, so an org already on Atlas plans as unchanged. Not with an org-level reader_auth entry. See Change a space's sign-in method.

readerIdentity

org or space

no

Whether one reader sign-in covers every space of the org (org) or each space signs in on its own; the org setting reader.identity_scope.

consent

map

no

Consent provider settings (provider required).

connectors

list

no

Connectors, any provider, by providerId and space.

executor

map

no

Privacy executor settings; ensureKey mints a key only if none exists.

privacy

map

no

{executor: cookiemunch} links the Cookie Munch rights executor in the same apply, with the key of the document's own consent: {provider: cookiemunch, connect: {orgProvisioningKey}} (checked against Cookie Munch at apply, never stored). unchanged once linked. Not together with executor.

webhooks

list

no

Outbound webhooks, space by slug.

tokens

list

no

API tokens of the caller, by ref; secret to --secrets-file (see API tokens).

status_monitors

list

no

Status monitors, each with its space.

brand

map

no

Organization brand.

mail

map

no

The organization's own mail provider, merged like PATCH /orgs/{orgId}/mail/provider: {provider, apiKey, domain, region, sender, baseUrl, webhookSecret}; write the two secrets as ${ENV}, which plan never prints and export never writes.

pages

list

no

Pages, from a folder (dir) or inline (table below).

sites

list

no

Sites and their member spaces (table below).

translationOverrides

map

no

Interface strings per language, { <locale>: { <key>: <text> } }.

orgSettings

map

no

Organization settings by registry key.

prune

boolean

no

Delete what this manifest created earlier and no longer declares, for the kinds present.

force

boolean

no

Overwrite pages edited in the studio since the last apply. Without it an apply that would overwrite one answers 409 and writes nothing. forumulate apply --force sets it.

Spaces (spaces[])

Key

Type

Create-time

Effect

id

string

n/a

Adopt an existing space (as in an export) instead of matching by slug.

slug

string

n/a

The space's address and its name in plans.

name

string

no

Display name.

type

space type

yes

DOCS, BLOG, FORUM, and the rest; a type cannot change.

visibility

PUBLIC, PRIVATE, …

no

Who can read it; left out, an existing space keeps its own.

seed

boolean

yes

Write the starter structure when creating.

preset

string

yes

Which named layout the reader wears.

shape

map

no

Shape patch for the type (patch, not copy).

settings

map

no

Merged onto the stored settings, key by key.

theme

map

no

As PATCH …/theme.

redirects

list

no

The manual redirect rules (rename redirects are kept).

collections

list

no

Collections: name, slug, parent, navigation fields.

categories

list

no

Forum categories.

monitors

list

no

Status monitors of a status space.

locales

list

no

Languages, { code, label }; removed ones go only under prune.

description

string

no

One sentence about the space (≤ 300 characters), shown by search engines and social cards. Shorthand for seo.description: the same value, kept in step on every apply. Naming both with different text is refused.

seo

map

no

Merged key by key.

translations

map

no

Name, description and SEO per language, merged per language.

piecePreset

string

no

The kind of piece new pages get.

frontDoor

map

no

The type's front-door options.

contactForm

map or file

no

A CONTACT space's form.

about

map or file

no

An ABOUT space's parts.

compare

map or file

no

A COMPARE space's catalog (catalog: file, matrix: CSV).

posts

list or {dir}

no

A blog's posts.

authors

map

no

A blog's authors.

topics

list

no

A forum's topics.

openapi

list

no

An OpenAPI reference imported as pages.

homeDoc

map or file

no

The space's intro page, with language twins.

translationOverrides

map

no

Interface strings for this space.

releases

list, {file} or {git}

no

A CHANGELOG space's releases, matched by version (v1.2.0 = 1.2.0); {file: CHANGELOG.md} reads Keep a Changelog; {git: {repo, since, include, cut, subjects, publish}} drafts one unpublished release per tag (or per day/week with cut) from conventional commits, for review; {file, git} takes both, the file winning. Published without mailing subscribers.

legalDocuments

list

no

A LEGAL space's documents: {kind, page, required, version, mode} binds a page as that document; version: bump starts a new edition only when the agreed words changed; mode: preview holds a draft for org members only until live launches it.

Pages (pages[])

An entry with dir is a folder entry and takes only dir, space, collection, locales, ignore and env (all CLI-side, never sent). Any other entry is a page, with:

Key

Type

Create-time

Effect

id

string

n/a

Adopt an existing page.

space

string

n/a

The space, by slug.

slug

string

n/a

The page's address.

title

string

no

Required for a page.

body

Markdown

no

The page's words.

sections

map

no

Markdown per named slot of the type.

seoTitle

string

no

The page's title for search.

seoDescription

string

no

Its description for search.

canonicalUrl

URL or ''

no

'' clears it.

metadata

map

no

Any key PATCH …/pages/{id}/metadata takes.

collection

string

no

The collection's slug.

order

integer

no

Position in the collection.

preset

string or null

no

The kind of piece.

parent

string or null

no

The page it nests under, by slug.

params

map

no

The kind's parameters.

translations

map

no

{ <code>: { title, body, sections, seoTitle, seoDescription, canonicalUrl, metadata, slug } }.

env

boolean

n/a

CLI only, never sent: substitute ${NAME} in body and sections.

Sites (sites[])

Key

Type

Create-time

Effect

id

string

n/a

Adopt an existing site.

slug

string

n/a

The site's name in plans.

name

string

no

Required to create; a rename afterwards.

spaces

list of slugs

no

Member spaces, in order; left out keeps the membership.

config

map

no

As PATCH …/sites/{id} takes config.

configMode

merge or replace

no

replace removes every key left out, and the plan lists each removal.

subdomain

string or null

no

The site's platform subdomain (acme for acme.<platform domain>): claimed if free, null releases it, left out keeps it.

The keys of domains, reader_auth, connectors, webhooks and status_monitors entries are those of the endpoint each one stands for, plus space or site by slug and an optional id; the API names any it refuses.

Reading the plan

The whole file on one page, for an agent or a first read: forumulate.com/setup.md. Validate in an editor with $schema: https://forumulate.com/schemas/2026-12-01/forumulate-yaml.json.

Declared secrets never reach the output of plan, apply or --json, errors included: a webhook's path and query, any value under a key like secretKey, and everything in --secrets-file. Give each webhook a ref so a plan can name it. On a terminal, --show-secrets shows them; it is refused when output goes anywhere else.

--vars-file <path> (repeatable; later files win, the shell beats them all; values of 8+ characters are masked in output) reads ${NAME}s from a dotenv file instead of a sourced script; a variable already set in the shell wins.

Under --diff, a page on a space with a declared domain prints its final address. On a space's own host that is https://host/<slug>, and the index redirects to the first page.

+ create    pages        docs/getting-started
+ create    pages        docs/getting-started@es
~ update    seo          contact  (seoTitle)
~ update    pages        legal/privacy  (content)
      content: edited outside the manifest since the last apply (live revision …) -> the declared Markdown
Plan: 2 to create, 2 to update, 0 to delete, 41 unchanged (already as declared: pages 30, settings 8, roles 3).

A field whose two sides are equal is never listed. A write-only value (a secret) prints <write-only> (changed, not shown).

+ creates, ~ updates (the fields in brackets; --diff prints each one from → to), - deletes (only with --prune). Unchanged steps are counted by unit, not listed: on a brand-new organization they are the entries whose declared value is already the built-in default (a setting, a role, a reaction), so nothing needs writing. --verbose lists them, and also lists what exists but the file does not mention (left alone, never deleted without --prune); --json always carries that list as extras. apply prints each step as it landed and whether reading it back matched what was written; a step that did not read back is READ BACK DIFFERS and the command exits 1.

Studio edits: conflicts

A page, post, release or staff topic someone edited outside this file since your last apply — in the studio, the console or through the API — is a conflict, not drift: overwriting it would throw their work away without anyone deciding to.

~ update    pages        docs/install  (content)
      CONFLICT edited in the studio by Dana Lee at 2026-11-10T09:12:44.000Z; apply refuses it (exit 5) unless --force
~ update    posts        news/launch  (content)
      CONFLICT edited outside the manifest by Dana Lee at 2026-11-10T10:02:13.000Z; apply refuses it (exit 5) unless --force
Plan: 0 to create, 2 to update, 0 to delete, 12 unchanged (already as declared: pages 12); 2 conflict(s) apply refuses without --force.
Edited outside the manifest since the last apply: apply refuses these (exit 5). Overwrite them with `forumulate apply --force`, or take the edits into your files and re-plan:
  forumulate pull content --space docs --only install
  forumulate pull blog --space news --kinds posts --only launch

plan --diff adds the paragraphs that differ (- live, + your file). plan --check exits 5 on a conflict, so CI tells an outside edit apart from an ordinary change (1). Then either:

  • Keep the edit: run the pull line it printed. It rewrites only those files from the live piece (--diff first shows what would change); commit them, and the next plan has no conflict. A release pulls as a file of its own (--kinds releases): copy its words into your changelog file. A forum topic has no file form, so the plan says to copy it into forumulate.yaml by hand.

  • Discard it: forumulate apply --force (force: true in the document, for the API) writes your files over it.

Without either, apply writes nothing and exits 5; the API answers 409 naming each piece, who edited it and when.

What counts as an edit: for a page, a revision made live since the one your last apply or forumulate publish made live. For a post, release or topic, a change to its body or its publication since your last apply. Only an apply records that point for them, so a post forumulate publish rewrote after an apply also reads as an edit. A change to a field other than the body (a topic's featured link, a post's series) is still drift: the plan shows it from → to and the apply writes your value back. On a legal page whose sub-processor section the platform generates, the sweep's own rewrites of that section never count; any other change does.

Prune

--prune (or prune: true in the file) deletes what this file created or adopted earlier and no longer declares: a page you deleted from the folder goes to the trash, a language you removed is removed, a site you dropped is deleted. Posts, authors, topics, translations of either, interface strings and organization settings are pruned the same way. Only the kinds the file declares are pruned — a file with no pages deletes no page, a blog with no posts deletes no post — and anything made by hand, which the file never managed, is reported and left alone. A pruned post or topic goes to the trash.

Large sites

An apply is split into requests of 20 changed pages (--batch <n>), because a request is cut off after 30 seconds while the work carries on. Each request also carries every page already settled, which costs a read and writes nothing; the last request is the whole file, and only it prunes. Re-running after an interruption picks up where it stopped: nothing is created twice.

Shown-once secrets and the by-hand list

apply prints a secret a step minted (an executor key, a webhook signing secret) only as <redacted; use --secrets-file>, in the report and in --json. Pass --secrets-file <path> and the file holds {"<unit>/<ref>": {"<field>": "<value>"}} for every one, mode 0600; a path that already exists must be 0600 or the run stops before anything is applied. For each domain waiting on you, the "By hand" list gives the proof of ownership: the verification file (verification file: serve https://<host>/.well-known/forumulate-verify/<token> with the contents forumulate-verify=<token>), and, for a whole hostname whose DNS you edit, the TXT record beside it ("or a verification file"). A path mount (basePath) or an sslip.io / nip.io name gets the file alone: the site that already answers the host is where the file goes, and a wildcard-DNS name has no DNS to edit. A host that already reaches our edge — an sslip.io or nip.io name for our IP, or any name whose DNS points at our edge address or cname.forumulate.com — says "same server as Forumulate: nothing to proxy" instead of a proxy rule.

Every heartbeat monitor's step carries its ping URL (pingUrl), on every apply — created, updated or unchanged. It holds the monitor's secret, so the CLI treats it like a shown-once value: --secrets-file f writes it under monitors/<space>/<name> ({"pingUrl": "https://…/status/ping/<id>/<token>"}), and stdout, human and --json, shows <redacted: written to f>. The file is merged, not replaced: a key an earlier apply minted stays in it. The URL is never in the stored run, and forumulate heartbeat ping-url <monitor> reads it again later.

API tokens: tokens

The last provisioning call can live in the file too: tokens mints an API token of the account running apply, keyed by ref (stored as the token's externalRef, so POST /pats with the same externalRef finds the same one).

version: 2
tokens:
  - ref: advernue-members            # get-or-create key
    name: Advernue members sync
    scopes: [members:manage]         # same vocabulary as POST /pats
    expiresInDays: 365               # 1–365; omitted is 90
    spaces: [community]              # optional: narrow to these spaces
  • The secret is a shown-once value: it is in the step as token, redacted on stdout, and written to --secrets-file under tokens/<ref>. Run apply with --secrets-file the first time.

  • Every token is pinned to the document's organization (constraints.orgIds); spaces narrows it further.

  • The same gates as POST /pats: a PAT running apply needs the coarse admin scope, an exchanged operator token needs a second factor in the last five minutes, and a partner link cannot mint. A refusal stops the plan before anything is written.

  • A token that already exists is unchanged and never rotated. Declaring other scopes or spaces for an existing ref is refused, naming the token: revoke it (forumulate pats revoke <id>) to mint a new one, or use a new ref. To get a new secret for the same grants, rotate it (POST /pats/{id}/rotate); the rotation keeps the ref.

  • Under prune: true, a token this file minted and no longer declares is revoked. Without prune it is listed as left alone.

Domains outside the manifest

The same steps, one at a time (each takes the hostname or the domain id, and --org / --space; under sites the same verbs are sites domains …):

forumulate domains list --space docs
forumulate domains setup docs.acme.com --space docs     # mode, pendingReason, message
forumulate domains verify docs.acme.com --space docs --activate false
forumulate domains activate docs.acme.com --space docs

domains setup prints mode, pendingReason when the API gives one (why a pending domain is pending), served, and the whole message, then the proxy origin and DNS state; --json prints the API's answer.

Before the first apply: the organization

plan and apply work ON an organization. Against one that does not exist (or that the token is not a member of) they stop with exit 4 — unless the file says it may be created: add orgName (or pass --create-org):

version: 2
org: acme
orgName: Acme
$ forumulate plan
+ create    org          acme  (Acme)
+ create    spaces       docs
+ create    pages        docs/getting-started
…
Plan: 14 to create, 0 to update, 0 to delete, 3 unchanged (…).

plan then previews the whole document against an empty organization (PUT /manifest/new-org?org=acme&name=Acme, a dry run that writes nothing, not even the org), and apply creates the organization (POST /orgs) and applies to it. Or create it yourself first:

forumulate orgs ensure acme --name "Acme" --external-ref customer-123   # safe to re-run
forumulate apply

orgs ensure reads GET /orgs/{slug} and creates the organization with POST /orgs only when that is a 404; with --external-ref the create is itself a get-or-create, so a retried provisioning run answers the same organization. It prints Created <slug> (<id>) or Exists: <slug> (<id>) (--json: {organization, created}), and exits 5 when the slug belongs to an organization the token cannot see.

Exit codes

0 done (or nothing to do — and also when a domain is only waiting for its verification file under onUnverified: hold), 1 a step failed or did not read back, or plan --check found changes, 2 the file or a flag is wrong (the message names the key), 3 the token cannot do this, 4 the organization does not exist, 5 a conflict (a page, post, release or topic edited outside the manifest since the last apply, without --force; or a page address another page already has), 6 this CLI is older than the oldest the API supports (upgrade with the command it prints; nothing was written).

Without the CLI

PUT /orgs/{orgId}/manifest takes the same document as JSON, with pages inline (body is the Markdown) and no org, dir or file names. It is a dry run that answers the plan unless you add ?apply=true; GET /orgs/{orgId}/manifest?export=true answers what is there now in the same shape (pages excepted — they are your files), which is how you start a forumulate.yaml for a site built by hand.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Manifest New Org

Operation

Request

What it does

Shortcuts

ManifestNewOrg_planNewOrg

PUT /manifest/new-org

Plan a manifest for an organization that does not exist yet (dry run only)

client.orgs.manifestNewOrg.planNewOrg()

The Forumulate teamUpdated