One file for the whole site: forumulate plan and apply
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 repositoryA 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 |
|---|---|---|---|
| The site manifest: the whole site, this page. |
| |
no | The publish manifest: which folder goes to which space. Either one space written flat ( |
|
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 |
|---|---|---|
| the space itself (settings merge key by key; an unknown key is refused by name) |
|
| a blog's, a forum's or a status page's own settings |
|
| a careers board's hiring configuration: |
|
| the space intro, |
|
| the look (deep merge; a key set to |
|
| your redirect rules; |
|
| a docs space's collections: |
|
| a forum's categories, a status page's monitors | their own endpoints |
| the languages the space publishes in |
|
| title, description, title template, site name, social card, verification… (merged per key) |
|
| one sentence about the space (≤ 300 characters); shorthand for |
|
| per language: |
|
| the layout and the front door's switches and heading |
|
| a CONTACT space's form: fields, the thank-you, |
|
| an ABOUT space's parts: mission, values, team, timeline, company facts… |
|
| a COMPARE space's catalog: products, criteria, values, scoring, freshness; |
|
| a BLOG space's posts and author registry (below) |
|
| a FORUM space's staff topics (below) |
|
| an API reference generated into a collection (below) |
|
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 }pipelineis the body ofPUT …/careers/pipeline(the stages in order, the rating scale, templates, blind review, the scorecard). Removing a stage that still holds applications needsmoveTo, exactly as the endpoint does.formis the body ofPATCH …/careers/form(the questions, the question bank, consent, retention, the after-send step); keys left out are kept.atsis 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.indexNowis the same switch assettings.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 }indexNowtells 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 namingindexNow, as the API does, with nothing written. What each type pings is in Robots, sitemaps and llms.txt.updateServices,sendWebmentions,acceptWebmentionsandautoApproveMentionsact 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 → skippedTwins. 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 ownlocales:), sov1.2.mdand a folder calleddein 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 ownslug.Front matter.
title(else the leading# heading, which is then taken out of the body),slug,seoTitle,seoDescription(ordescription),canonicalUrl(orcanonical),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: trueto leave a file out, and every page settingforumulate publishreads (icon,hideFromNav,tags,layout,schemaType, …): that is every keyPATCH …/pages/{id}/metadatatakes, in any casing, and the API's front matter JSON Schema lists them all.api:(or Mintlify'sopenapi:) names the operation a page documents.kind,customHeadandcmsBindingare refused by the manifest (preset:is the page's kind here). Alocale: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 a01-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 sayscategory: Getting startedgoes 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 acategory:, 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/pagesfor it.Nesting, kind and parameters. In a
pagesentry these areparent(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) andparams(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, andorderorders 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 atranslationsentry) andonSlugTaken(the slug you name is the page; a taken slug is never suffixed).Links and images. A relative link to another
.mdfile ([x](../usage.md),onboarding/go.md#step-2, anhref="…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/golinks to/docs/usage. A link to a.mdfile that no page of the folder came from (missing,ignore:d, a draft) is left as it is andplanprintswarning: <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 todir(the same globs asforumulate publish:*within a segment,**across folders,?; a pattern without a/matches a name at any depth).planprintsskipped <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, withskipped <path> (not Markdown); images and attachments are not listed, they are uploaded when a page uses them. Names starting with.or_andnode_modulesare 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 its01-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 aslug:.${NAME}in a body. Off by default (a docs page that prints${HOME}is content).env: truein a file's front matter, on apagesentry withdir(every file under it) or on an inline page turns it on for that page's body andsections. Fenced blocks andinline codeare never changed. A name that is set (even to the empty string) is replaced; one that is unset stays as written,planprintswarning: <file>: ${NAME} is not set …, andapplyrefuses until it is set (--allow-unset-envpublishes 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.
Moving a legal page
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.
Changelog releases and legal documents
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: truepublishes a period's release on the first apply after the period closes (midnight UTC at the start of the next day or week). Usepublish: {after: 2d}to wait that much longer (hourshor daysd). Subscribers are not emailed, as for any manifest release. The plan showsstatus: draft, for review (from git, publishes 2026-11-09). Each release is published once: if someone unpublishes it later, it stays unpublished.publishneedscut: dayorcut: week.subjects: anylists 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 anyonforumulate 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 }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 (nullclears it).customization— the topic's own CSS, partial theme, share card and JSON-LD. Keys merge;nullclears one key, or every key for the whole object.postAs: orgpublishes the topic under the organization (its name, its email-branding logo);byline({name, url, avatarUrl}) orbylines({name, role, …}or{author: key}from the space's author registry) name who it credits.postAscannot be combined with the other two;postAs: selfputs 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 —pinin 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 pollPOST …/forumtakes. 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,closesAtmust 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 |
|---|---|
| Exactly one. |
| The collection, by name or slug; created when the space has none by that name. |
|
|
| Prefixed to every generated slug ( |
| The tree's shape (Importing). |
| The landing page's address, name and meta description. |
| The reference's title; also what the landing slug is derived from. |
| Globs ( |
|
|
| Empty the collection first. |
| Per tag: SEO words and translations. |
| A list of |
| The page operations without a tag are filed under. Unset: |
|
|
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: 5orgSettings 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.
| What |
|---|---|
| Adds the domain and holds it. The step's |
| Also checks ownership — |
| 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:
| When the proof is not served yet |
|---|---|
| The domain stays claimed, the step is |
| The first refusal stops the run there (the rest of the plan is skipped) with the domain and the step named, and |
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 |
|---|---|---|---|
|
| n/a | Required; a tool that does not know the version refuses the file. |
| string | n/a | CLI only, never sent: the organization (or |
| string | n/a | CLI only, never sent: the organization may not exist yet — |
| list | no | Spaces to create or update, by |
| list | no | AI knowledge sources, matched by title. |
| list | no | Custom domains on a site or a space, with |
| list | no | Reader sign-in, merged like |
|
| no |
|
|
| no | Whether one reader sign-in covers every space of the org ( |
| map | no | Consent provider settings ( |
| list | no | Connectors, any provider, by |
| map | no | Privacy executor settings; |
| map | no |
|
| list | no | Outbound webhooks, |
| list | no | API tokens of the caller, by |
| list | no | Status monitors, each with its |
| map | no | Organization brand. |
| map | no | The organization's own mail provider, merged like |
| list | no | Pages, from a folder ( |
| list | no | Sites and their member spaces (table below). |
| map | no | Interface strings per language, |
| map | no | Organization settings by registry key. |
| boolean | no | Delete what this manifest created earlier and no longer declares, for the kinds present. |
| 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. |
Spaces (spaces[])
Key | Type | Create-time | Effect |
|---|---|---|---|
| string | n/a | Adopt an existing space (as in an export) instead of matching by slug. |
| string | n/a | The space's address and its name in plans. |
| string | no | Display name. |
| space type | yes | DOCS, BLOG, FORUM, and the rest; a type cannot change. |
| PUBLIC, PRIVATE, … | no | Who can read it; left out, an existing space keeps its own. |
| boolean | yes | Write the starter structure when creating. |
| string | yes | Which named layout the reader wears. |
| map | no | Shape patch for the type (patch, not copy). |
| map | no | Merged onto the stored settings, key by key. |
| map | no | As |
| list | no | The manual redirect rules (rename redirects are kept). |
| list | no | Collections: |
| list | no | Forum categories. |
| list | no | Status monitors of a status space. |
| list | no | Languages, |
| string | no | One sentence about the space (≤ 300 characters), shown by search engines and social cards. Shorthand for |
| map | no | Merged key by key. |
| map | no | Name, description and SEO per language, merged per language. |
| string | no | The kind of piece new pages get. |
| map | no | The type's front-door options. |
| map or file | no | A CONTACT space's form. |
| map or file | no | An ABOUT space's parts. |
| map or file | no | A COMPARE space's catalog ( |
| list or | no | A blog's posts. |
| map | no | A blog's authors. |
| list | no | A forum's topics. |
| list | no | An OpenAPI reference imported as pages. |
| map or file | no | The space's intro page, with language twins. |
| map | no | Interface strings for this space. |
| list, | no | A CHANGELOG space's releases, matched by |
| list | no | A LEGAL space's documents: |
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 |
|---|---|---|---|
| string | n/a | Adopt an existing page. |
| string | n/a | The space, by slug. |
| string | n/a | The page's address. |
| string | no | Required for a page. |
| Markdown | no | The page's words. |
| map | no | Markdown per named slot of the type. |
| string | no | The page's title for search. |
| string | no | Its description for search. |
| URL or | no |
|
| map | no | Any key |
| string | no | The collection's slug. |
| integer | no | Position in the collection. |
| string or null | no | The kind of piece. |
| string or null | no | The page it nests under, by slug. |
| map | no | The kind's parameters. |
| map | no |
|
| boolean | n/a | CLI only, never sent: substitute |
Sites (sites[])
Key | Type | Create-time | Effect |
|---|---|---|---|
| string | n/a | Adopt an existing site. |
| string | n/a | The site's name in plans. |
| string | no | Required to create; a rename afterwards. |
| list of slugs | no | Member spaces, in order; left out keeps the membership. |
| map | no | As |
|
| no |
|
| string or | no | The site's platform subdomain ( |
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 launchplan --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
pullline it printed. It rewrites only those files from the live piece (--difffirst 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 intoforumulate.yamlby hand.Discard it:
forumulate apply --force(force: truein 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 spacesThe secret is a shown-once value: it is in the step as
token, redacted on stdout, and written to--secrets-fileundertokens/<ref>. Runapplywith--secrets-filethe first time.Every token is pinned to the document's organization (
constraints.orgIds);spacesnarrows it further.The same gates as
POST /pats: a PAT runningapplyneeds the coarseadminscope, 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
unchangedand never rotated. Declaring other scopes or spaces for an existingrefis refused, naming the token: revoke it (forumulate pats revoke <id>) to mint a new one, or use a newref. To get a new secret for the same grants, rotate it (POST /pats/{id}/rotate); the rotation keeps theref.Under
prune: true, a token this file minted and no longer declares is revoked. Withoutpruneit 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 docsdomains 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 applyorgs 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 |
|---|---|---|---|
| Plan a manifest for an organization that does not exist yet (dry run only) |
|