Authoring: everything a documentation space can do
A reference for the features that exist and have had no guide. Each section says what the thing is, which calls do it, and the one constraint that is not obvious from the endpoint name.
Creating a space
See Creating a space for seed, shape and preset.
Importing
See Importing documentation. A repository sync records each page's source file in metadata.sourcePath, which an edit link's {path} uses.
Collections
POST GET /orgs/{orgId}/spaces/{spaceId}/collections
GET PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/collections/{collectionId}
POST /orgs/{orgId}/spaces/{spaceId}/collections/reorder
POST /orgs/{orgId}/spaces/{spaceId}/collections/{collectionId}/pagesA collection is the group a page is filed under and the heading the sidebar draws. POST takes a name and derives the slug from it when you send none.
A collection's slug is a public address — …/c/{slug} is a reader page — so re-slugging one moves that address and writes the redirect for it. A page's own address does not change when you move it between collections, which is the point of filing pages this way. DELETE takes the collection's pages to the trash with it and says how many went: { "ok": true, "pages": 12 }.
How a collection sits in the rail: nav
POST and PATCH take a nav object. PATCH merges it into what is stored, so {"nav": {"collapsed": true}} keeps an icon you set earlier.
Key | What it does |
|---|---|
| Starts the group closed when |
| An emoji, an icon name from |
|
|
|
|
|
|
| With |
PATCH also takes null for any nav key, which removes it so the collection follows the theme again: {"nav": {"hubCards": null, "linkHub": null}}.
The collection's landing page: description, coverUrl, seo
Every collection has a landing page at …/c/{slug}: its name, a trail back to the space (and its section), its pages with their summaries, last-updated dates and reading times, the pages nested under them as sub-groups, and previous/next. POST and PATCH take three fields that shape it:
Field | What it does |
|---|---|
| One or two sentences (≤ 500). The subtitle under the title, the card text on the space's landing page, and the meta description. |
| An image URL or an upload path. A banner above the title, and the page's social image. |
|
|
The longer introduction under the title is the collection's hub intro (PUT …/hubs/collection/{slug}/intro, or the studio's Intro button). The studio's gear button beside each collection edits all of the above.
A landing page with one page, no description and no introduction says nothing a link to that page would not, so it is noindex and left out of the sitemap; every other landing page is in the space's sitemap.xml, carries a CollectionPage + ItemList + BreadcrumbList description for crawlers, and has its own social card (…/c/{slug}/social-card.png).
Filing a page under another: append, not prepend
POST …/pages/{pageId}/reorder with a new parentId (or collectionId) and no afterId now appends the page after its new siblings. It used to put it first, so a script that created pages and then filed each under a parent got them in reverse. afterId: null still means "first"; omitting afterId with the parent unchanged leaves the page where it is. Pages created at the same moment under one parent are serialised, so they keep the order they were created in.
Reordering is a move, not a replace
POST /orgs/{orgId}/spaces/{spaceId}/collections/reorder
{ "ids": ["<collectionId>", …] } → { "ok": true }A partial list is legal, and is the useful case. The ids you name are written to the front of the space's order, in the order you gave them. Every collection you did not name keeps its place behind them, in the order it already had — their positions relative to each other do not change. So {"ids": ["<one id>"]} promotes one collection to first and disturbs nothing else, and moving one thing is never a reason to restate the rest. Maximum 200 ids per call.
Two bodies are refused, both 422 validation.failed, with the ids at fault in error.details.ids: an id no collection in this space has, and the same id listed twice. Those used to be dropped from the list instead, so a reorder with a typo in it answered { "ok": true } and left the order exactly as it was, leaving a caller no way to tell a reorder that worked from one that was ignored (Crossly E-63). A reorder is not a filter: if the list does not describe this space, the right answer is to say so.
Versions
GET POST /orgs/{orgId}/spaces/{spaceId}/docs/versions
PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/docs/versions/{key}
POST /orgs/{orgId}/spaces/{spaceId}/docs/versions/{key}/make-default
GET PUT /orgs/{orgId}/spaces/{spaceId}/docs/versioningA docs space can publish several versions of itself, for example v1, v2 and next. Readers move between them with the version picker. default is the primary version, the one a bare address serves. In the console, the space's Settings fold has a Versions group that does everything below.
Each version lives at its own address:
Address | Serves |
|---|---|
|
|
|
|
|
|
| that page as Markdown |
The same form works on a path mount (acme.com/docs/v2/install) and on a custom domain (docs.acme.com/v2/install). The switcher, the sidebar tree, previous and next, and the language switcher all link inside the version you are reading. An address that names a key the space does not have answers 404. The older ?version=<key> form still works, so links you already published keep working. Its canonical is the path form.
Creating a version copies every page. It is not a pointer. Each version is a real page tree, and pages share a family key so a reader can switch to the same page in another version instead of landing at the root. Plan how many versions you keep.
Why copies and not a live overlay. We considered a lighter model where a version stores only the pages that differ and falls back to the default for the rest. We did not build it, because a version is a promise that its text does not change. With an overlay, editing a page in the latest version would also change it in every older version that did not have its own copy, and readers of v1 would see v2 text with no notice. Copies cost storage. In exchange, each version keeps exactly the text it was published with.
A version key must match ^[a-z0-9][a-z0-9._-]{0,39}$: lowercase, up to 40 characters. Keys that are already address segments in a docs space are refused: c, me, tags, print, md, rss.xml, sitemap.xml, social-card.png and manifest.webmanifest. The default version's label comes from the space's defaultVersionLabel setting, or "Latest" when it is not set.
Creating a version never changes an existing page's id. The originals stay default and the copies get new ids. POST answers { "versions": […], "cloned": <pages copied> }.
Making another version the default. POST …/versions/{key}/make-default with { "retireAs": "v1" } moves that version's pages to the bare address. The outgoing default keeps its pages under retireAs, with its label (or retireLabel), badge and banner. Page ids do not change, so revisions, comments and links by id stay with the same text. The space's home page moves to the same page in the new default. Subscribers to version.default_changed are told.
PATCH …/docs/versions/{key} changes a version's label and reader extras. Keys merge, so a key you do not send keeps its value:
Key | Meaning |
|---|---|
| The name in the switcher. On |
| A short pill beside the label, up to 24 characters. |
|
|
|
|
|
|
Canonicals and the sitemap. PUT …/docs/versioning with { "canonical": "self" | "latest" } decides how search engines see older versions:
self(the default): every version's page is its own page. Its canonical is its own/<version>/<slug>address, and the space sitemap lists it, except for hidden and archived versions.latest: a page that also exists in the default version uses that page as its canonical, and other versions stay out of the sitemap. Use this when old versions are close copies that you want search engines to treat as the current page.
Listing pages by version. GET …/pages and GET …/pages/inventory carry versionKey and locale on every row and take ?version=<key>&locale=<code> to list one variant. Absent lists every variant, primary first among rows that share a slug. A key the space does not declare answers 422 naming the ones it does.
Locales
GET POST /orgs/{orgId}/spaces/{spaceId}/docs/locales
PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/docs/locales/{code}
POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/ai/translate
GET PUT DELETE /orgs/{orgId}/translation-overrides
GET /public/orgs/{orgId}/i18n/{locale}The same mechanism as versions, keyed by locale. …/ai/translate fills a locale from the primary using your own AI key (see AI features and bring-your-own-key); the number of locales you may add is a plan quota.
Every page gets a twin in every declared language. Adding a language clones the pages that exist then, and a page created afterwards — through the pages API, /items, an import or MCP — gets an unpublished twin for each declared language at creation, so a later page is as translatable as an earlier one. A twin can also be created explicitly with locale on the page-create door: POST …/collections/{id}/pages with { "slug": "webhooks", "title": "Webhooks", "locale": "es" }. It is a draft until published, and it then appears in ?locale=es listings.
PATCH …/docs/locales/{code} with { "label": "Español" } renames a language as the reader's switcher shows it. {code} may be default: the source language is labelled "Original" until you name it ({ "label": "English" }). Only the label changes — the code is the translation's address and stays.
Language addresses
Every language has its own path. The language code goes right after the space's address, and the original language has no code:
Address shape | Original | Spanish |
|---|---|---|
Path of your own domain |
|
|
A space on a site |
|
|
Our address |
|
|
Blog, forum, and so on |
|
|
Legal documents |
|
|
Versions |
|
|
The code is never at the root of your domain, because the root belongs to your own site and only the space's path is routed to us. Your reverse proxy or Traefik rule for /docs already covers /docs/es/…; nothing changes there.
Only a declared language is a path segment. A code you have not added with
POST …/docs/localesis read as a page name, so a page calledesordekeeps its address.When a language and a page share a name, the language wins. If you add Spanish to a space that already has a page called
es, then/docs/esis the Spanish front door. The page is still at/docs/es?locale=default, and every link, canonical and sitemap entry we write for it uses that address.defaultis our name for the original, so no language can take it. Renaming the page (with a redirect from the old name) is the cleaner fix.The interface follows the address.
/docs/es/…shows the navigation, "On this page", "Updated", the pager and the not-found page in Spanish too, when Spanish is an interface language.?lang=<code>in the same address still picks the interface language. A reader's saved language preference does not change a path-addressed page, because the page is cached by its address.Links stay in the language. The navigation, sidebar, breadcrumbs, previous/next, cards, search results and front-door redirects of a Spanish page all point at Spanish addresses. The language switcher and
hreflangname every language.Old
?locale=links still work.…/webhooks?locale=es, and?lang=eswhen Spanish is a declared content language, answer301with…/es/webhooks, and every other parameter is kept. Your redirect rules also apply inside a language: a rule for/oldanswers/es/oldwith/es/new.
A page with no translation in a declared language answers 404 there, unless the space sets theme.docs.localeFallback: "source". Then the original is served under a "not translated yet" notice (see Theme a documentation space).
What search engines see
Signal | What we publish |
|---|---|
| The language of the page's words: the translation's code, or the space's declared language for the original. |
| Each translation's own address, on your custom domain when the space has one. |
| One per published translation, the original under its own language, and |
| The page's language and its published translations, written |
JSON-LD | The same language as |
| Every published translation at its own address, each with |
A fallback ( | Canonical to the original, and not in |
Keep ?locale= instead. Set theme.i18n.urlStyle: "query" on the space, or on its site so every space in the site uses it (a space's own setting wins). Then translations are at …/<page>?locale=<code>, path segments are not read as languages, and canonicals, hreflang and the sitemap use the query form. "path" (or unset) is the default.
A page in a right-to-left language (ar, he, fa, ur …) is laid out right to left, whatever language the reader's interface is in; when the space itself declares a right-to-left language, the navigation and rail mirror too.
translation-overrides is for the interface strings, not the content — "Was this helpful?" in Portuguese.
Listing needs docs.read; creating or deleting needs docs.write.
Snippets (reusable content)
POST GET /orgs/{orgId}/spaces/{spaceId}/reusable-content
PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/reusable-content/{id}
GET POST /orgs/{orgId}/library/reusables
DELETE /orgs/{orgId}/library/reusables/{id}A named block document stored once and referenced from any number of pages by a reusableRef block — :::reusable{name="install-warning"} in markdown. It is resolved at render, so editing the snippet updates every page that uses it.
library/reusables is the organization-wide version, available in every space.
The name is unique per space, and re-creating an existing name is a 409 name.taken naming the snippet that holds it. Use PATCH to change one, or pick another name. (This guide said the opposite — a silent 200 with the old content — which the API has never done.)
Variables
GET PUT /orgs/{orgId}/spaces/{spaceId}/variables
DELETE /orgs/{orgId}/spaces/{spaceId}/variables/{key}
GET PUT /orgs/{orgId}/library/variables
DELETE /orgs/{orgId}/library/variables/{key}Key/value pairs substituted at render, referenced by a variableRef inline node. The product name, the current release, the support address.
Each row reports its scope — org for one defined once for every space, space for this space's own. A space variable shadows an org one of the same name.
Variables are typed: string, number, boolean, url. The value is checked on write, and the messages are exact — That is not a number., Use true or false., A URL must start with http:// or https://.
A url is validated by parsing it, not by a regular expression, because https:/\evil.test passes a regex and fails a parser, and the parser is what a browser will use.
Glossary
GET PUT /orgs/{orgId}/spaces/{spaceId}/glossaryTerms the reader sees underlined, with the definition on hover.
PUT replaces the whole list. There is no merge and no per-term endpoint, because a glossary is edited as a list and partial merges of a list are how entries get duplicated. Read, modify, write back the whole thing.
The server rewrites what you send: it trims, drops any row with an empty term or definition, deduplicates case-insensitively with last one winning, and returns the result sorted alphabetically. A round-trip will not match your payload byte for byte. Maximum 200 entries; term up to 80 characters, definition up to 500.
Redirects
GET PUT /orgs/{orgId}/spaces/{spaceId}/redirects
PUT /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/redirectCovered in Moving an existing site onto Forumulate. Whole-list replace, 500 maximum. from is a path; to may be a path or an absolute https:// URL, because a redirect target may legitimately leave the space.
Either side may end in *: from then matches everything below it, and a to ending in * carries the matched remainder (/old/* → /new/* sends /old/a/b to /new/a/b). The longest matching wildcard wins and an exact rule always beats a wildcard.
A rule may also set status (301/302/307/308, default 308), match.query (exact query values the request must carry), passQuery (carry or drop the incoming query) and type: "rewrite" (serve another path of the same space without changing the URL). See Status codes, query strings and rewrites.
Rules we write ourselves — when a page is renamed, or a collection is re-slugged — are kept when you PUT the table back, unless your list claims the same from.
Deleting and restoring
DELETE /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}
DELETE /orgs/{orgId}/spaces/{spaceId}/pages (ids or collectionId)
GET /orgs/{orgId}/spaces/{spaceId}/pages/trash
POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/restore
DELETE /orgs/{orgId}/spaces/{spaceId}/pages/trash (purge)A delete is a move to the trash, and restoring is a single call. Two things it does that are not obvious:
The children of a deleted page come up a level. A live page hanging off a deleted one is reachable by URL and in no navigation tree, so each child is re-parented to the deleted page's own parent — the top level if it had none. The same applies to the bulk door.
Restoring puts them back. The response says how many:
POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/restore
→ { "ok": true, "slug": "install", "renamedFrom": null,
"detached": false, "reattachedChildren": 3 }reattachedChildren— children the delete had moved up, now back under this page. A child somebody moved deliberately in the meantime stays where they put it.detached: true— the page's own parent is gone, so it came back at the top level.renamedFrom— a page took its slug while it was in the trash, so it came back under a free one.
One level per delete: if you delete a page and then its parent, restoring either one returns the child to that one, not to both.
Snapshots
GET POST /orgs/{orgId}/spaces/{spaceId}/snapshots
DELETE /orgs/{orgId}/spaces/{spaceId}/snapshots/{id}
POST /orgs/{orgId}/spaces/{spaceId}/snapshots/{id}/restoreSpace-wide version history: one named label capturing every published page's live revision, restorable in a single action. This is how you undo a bad edit that spanned twenty pages without rolling each one back.
It captures the default version only, and only pages that are published. Drafts and non-default versions are not in a snapshot, so a restore will not bring them back. The listing returns the most recent 200.
A restore answers with four counts and takes one option:
Field | What it is |
|---|---|
| Pages pointed back at their captured revision. |
| Captured pages whose page or revision no longer exists. |
| Published pages that did not exist when the snapshot was taken. |
| How many of those |
|
|
What a restore does not do
A restore points every captured page back at the revision it had. It is not a rewind of the whole space, and these are the four things it leaves alone:
Left alone | Why, and what to do instead |
|---|---|
Pages created after the snapshot | They stay published by default. The response counts them as |
Pages deleted after the snapshot | A deleted page is not brought back. Restore it from the trash first, then restore the snapshot. Each one is counted in |
Drafts | An unpublished draft is untouched — a restore changes what readers are served, not what you are working on. |
Other versions and locales | A snapshot is of the |
POST /orgs/{orgId}/spaces/{spaceId}/snapshots/{id}/restore
{ "newPages": "unpublish" }
→ { "restored": 18, "skipped": 1, "addedSince": 3, "unpublished": 3 }unpublish is reversible: the pages, their revisions and their place in the tree all stay, so publishing one again is a single call. keep is the default, because most callers send no body at all and a restore that started unpublishing pages on them would be the worst possible surprise from a word like "restore".
Templates
Three different things share the word. Pick carefully.
Page templates — a saved page body to start new pages from.
GET POST /orgs/{orgId}/spaces/{spaceId}/page-templates
DELETE /orgs/{orgId}/spaces/{spaceId}/page-templates/{id}
POST /orgs/{orgId}/spaces/{spaceId}/page-templates/{id}/new-pageorgWide: true makes a template available in every space. The flag is the scoping, so you cannot narrow it afterwards — recreate it.
Space templates — scaffolding for a whole space.
GET /orgs/{orgId}/space-templates
PATCH DELETE /orgs/{orgId}/space-templates/{id}
POST /orgs/{orgId}/spaces/{spaceId}/apply-template
POST /orgs/{orgId}/spaces/{spaceId}/save-as-templateGET /orgs/{orgId}/space-templates is the catalogue. Every entry says where it came from in source:
| What it is |
|---|---|
| A starting point we ship. Some also create the collections to file pages in — those say so in their name. |
| One this organization saved from a space with |
| One another organization published. |
Apply any of them with POST …/apply-template and {"templateId": "<id>"}. The answer reports every page the template names:
{
"created": 3,
"skipped": ["overview"],
"failed": [{ "slug": "errors", "code": "quota.exceeded", "message": "…" }]
}skipped is a page that was already in the space — applying is a merge, so it is left alone. failed is a page that was refused, with the error. A 200 with a non-empty failed is a partial apply: the pages that landed stay, and applying the same template again retries only the ones that did not.
There is an older, deprecated route — GET /orgs/{orgId}/templates — that lists only the documentation scaffolds. Every key it returns is an id in the catalogue above, so use the catalogue; nothing needs the old route, and it is not going away on a date.
Form templates — required fields on a forum category, so a bug report arrives with a version number.
GET POST /orgs/{orgId}/spaces/{spaceId}/form-templates
POST /orgs/{orgId}/spaces/{spaceId}/form-templates/attach
GET PATCH DELETE /orgs/{orgId}/spaces/{spaceId}/form-templates/{id}
GET /public/orgs/{orgId}/spaces/{spaceId}/forum/categories/{categoryId}/form-templateThey only take effect when community.enable_form_templates is on and the category has one attached. Then a topic with a blank required field is rejected, naming the fields that are missing.
Change requests
POST GET /orgs/{orgId}/spaces/{spaceId}/change-requests
GET …/change-requests/eligibility
GET PUT …/change-requests/merge-rules
GET …/change-requests/{crId}
POST …/{crId}/approve /close /merge /rebase /review
POST …/{crId}/request-review /agent-review /agent-run
POST GET …/{crId}/comments
GET …/{crId}/conflicts /merge-blockers /pages
GET POST …/{crId}/reviewers
DELETE …/{crId}/reviewers/{reviewerId}
POST /orgs/{orgId}/spaces/{spaceId}/github/change-requests/{crId}/pull-requestA branch of page drafts reviewed and merged as a unit. Default merge rules: no approvals required, not all reviewers required, and a "changes requested" verdict blocks.
Four things about the gate:
The author's own approval does not count toward the required number. It is recorded and shown — it just never satisfies the count. The blocker message says "from someone other than the author" so the number does not look wrong to the one person most likely to be reading it.
One row per person, newest verdict wins. Approving twice is one approval.
Approvals go stale when a newer revision lands after them — but the stale blocker is inert unless a rule actually asks for approvals, so a space that requires none is never blocked by one.
Read
GET …/{crId}/merge-blockersrather than inferring from status. Status and the gate were once two mechanisms that never met, which is how the approve button came to satisfy nothing.
Styleguide rules for change requests
An organization can keep a list of content rules that the docs agent checks every change request against, on top of its built-in review heuristics. Use them for banned phrases, terminology you want standardized, or a regular expression that catches a pattern such as a leaked internal hostname. Managing them needs org.manage_settings.
POST /orgs/{orgId}/content-rules
{
"pattern": "simply",
"message": "Avoid 'simply'; it dismisses the reader's difficulty.",
"severity": "warn"
}Field | Required | Notes |
|---|---|---|
| yes | Up to 300 characters. Matched case-insensitively. |
| yes | What the reviewer sees when it matches. Up to 300 characters. |
| no | Treat |
| no |
|
Without isRegex the pattern is a plain substring match. Each enabled rule reports at most one hit per page, with the matched text. GET /orgs/{orgId}/content-rules lists the rules, newest first, and DELETE /orgs/{orgId}/content-rules/{id} removes one.
Presets and shape
PUT /orgs/{orgId}/spaces/{spaceId}/preset
POST /orgs/{orgId}/spaces/{spaceId}/shape/presets/{name}
PUT PATCH /orgs/{orgId}/spaces/{spaceId}/shape
GET /space-types/presets
GET /space-types/{type}/presetsA layout preset picks the index layout, the piece layout and the front door together — PUT …/preset with at least one of preset, piecePreset, frontDoor.
A shape preset is applied with POST …/shape/presets/{name} and is merged, not replaced: a space that renamed a slot and then applied a preset meant to keep the rename, and applying the same preset twice is the same as applying it once. A preset for the wrong space type is a 400 naming the types it belongs to; an unknown name is a 404.
PUT …/shape replaces the whole override, including sections and params. A second PUT that sent only params wiped the sections — with a 200 and no mention of what went. Use PATCH unless you mean to replace everything.
The kinds of value a param can hold
params.add[].type and params.set.*.type take exactly these. This is the list — the declaration, the write enum, the console dropdown and the value check are all built from or tested against it.
| A value looks like | Notes |
|---|---|---|
|
| Up to 2000 characters. |
|
|
|
|
| Any subset of |
|
|
|
|
| A URL the console previews, or a path on this site such as |
|
| |
|
|
|
|
| Not |
|
| Not |
|
| Every part optional; a fifth key is refused, because |
|
| Always an array, even for one target — a single reference is a one-element array. Up to 50 ids. |
null clears any param: a value that was set can always be unset. So does an empty (or all-whitespace) string — emptying a box in the console must not differ from never filling it in — and either way what is stored is null.
A relation also takes relationSpaceId — the space whose pieces the field points at; omit it for a relation inside the same space. It says where to look, not what is allowed: the console offers that space's pieces and the JSON Schema names it, and no write door refuses an id from another space in the org. Ids are not checked for existence either — an id that points at nothing, or at a piece a reader may not see, resolves to nothing on the public page rather than failing the write.
Resolve the ids a relation holds with GET /public/orgs/{orgId}/relations?ids=a,b,c, which answers titles and reader URLs for the public pieces among them.
Feedback
POST /public/orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/feedback
POST /public/orgs/{orgId}/spaces/{spaceId}/ask/{queryId}/feedback
POST /orgs/{orgId}/spaces/{spaceId}/docs/ask/{queryId}/feedback
GET /orgs/{orgId}/spaces/{spaceId}/docs/analytics
GET /orgs/{orgId}/spaces/{spaceId}/docs/feedback
GET /orgs/{orgId}/spaces/{spaceId}/docs/feedback.csv"Was this helpful?" — { "rating": "up" | "down", "comment": "…" }, comment up to 1000 characters. No authentication, because the people whose opinion you want are readers. A widget asking on a four-step scale (theme.docs.feedback.scale: 4) also sends score (1–4); the server then derives rating from it (3–4 is up), so the up/down counts keep one meaning.
How the widget looks and where it sits is theme.docs.feedback (see Theme a documentation space). metadata.hideFeedback: true removes it from one page. Page views are counted either way.
It answers 204 with an empty body. Do not try to parse the response.
Read it back through …/docs/analytics, which aggregates to per-page up/down counts and a daily series of views, feedback and searches.
The written comments are GET …/docs/feedback: newest first, { comments: [{ id, pageId, rating, score, comment, createdAt }], page }, filtered by pageId, rating (up/down) and sinceDays (1–365, default 90), paged with limit and cursor. Who left a comment is never in it. …/docs/feedback.csv is the same list as a spreadsheet (createdAt,page,rating,score,comment); a comment starting with =, +, - or @ is prefixed with ' so it opens as text. Both need docs.read; the console shows them under Analytics.
GET /public/orgs/{orgId}/spaces/{spaceId}/pdf
GET /public/orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/pdf
GET /public/orgs/{orgId}/spaces/{spaceId}/export
GET /orgs/{orgId}/export
GET /users/me/exportThe whole space as one document, or one page. Both take ?version= and ?locale=. The per-page route exists because a 119-page space is not what the sidebar's "Download PDF" means to the person clicking it.
A page with no published revision is a 404, so a draft's existence never leaks through the PDF route.
llms.txt, llms-full.txt, skill.md and per-page markdown
GET /public/orgs/{orgId}/spaces/{spaceId}/llms.txt
GET /public/orgs/{orgId}/spaces/{spaceId}/llms-full.txt
GET /public/orgs/{orgId}/spaces/{spaceId}/skill.md
GET /public/orgs/{orgId}/spaces/{spaceId}/pages/{pageId}.md
GET /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/markdownllms.txt is an index with a line per page. llms-full.txt is every published page concatenated. skill.md packages the space as an agent skill. The per-page .md is also served at https://<your-address>/<slug>.md.
On a documentation page the actions bar offers these to readers: copy, view as Markdown, open in ChatGPT, Claude or Perplexity, connect over MCP, and one-click "Add to Cursor" / "Add to VS Code" installs of the space's MCP server. Which ones, in what order and in what style is theme.docs.pageActions — see the docs theme guide.
On a single-space custom domain, llms.txt, llms-full.txt and skill.md are served at the address root.
All of these honour the per-space AI-ingestion opt-out. Set it with PUT /orgs/{orgId}/spaces/{spaceId}/discovery, and these endpoints answer 404 — "This space does not offer its content to AI tools." It is a 404 and not a 403 on purpose; a 403 confirms the space exists.
The PDF routes deliberately do not check it. A space that declined to be harvested wholesale has not declined to be printed.
The landing page: home-page versus home-doc
GET PUT /orgs/{orgId}/spaces/{spaceId}/home-page { "homePageId": "…" | null }
GET PUT /orgs/{orgId}/spaces/{spaceId}/home-doc { "doc": { … } | null }Two different things, and the space's preset decides which one is used, not which one you set.
|
| |
|---|---|---|
What it is | An existing page, nominated as the landing page | A block document rendered above a fixed listing |
For | Documentation-shaped spaces whose front door is a page | Blog, forum, roadmap, changelog, status, contact — whose listing has a fixed shape, so their landing cannot be an ordinary page |
The rule:
preset === "landing"→homeDocrenders.homePageIdis never read.any other preset →
homePageIdwins; if unset, the first page in reading order.homeDocis never read.
They never compete. Setting both is not an error and produces no warning — one is simply dead, depending on the preset. If you set one and nothing changed, check the preset first.
Three more facts:
homePageIdself-heals. It is only used if the page is still published — hidden or not:hideFromNavkeeps it out of the tree and keeps it the landing page. A deleted or unpublished home page falls back to the first page rather than sending every reader to a dead id.theme.docs.homeAt: "root"renders the home page AT the space root instead of redirecting to its slug. The page's own address keeps answering, with its canonical pointing at the root. See Theme a documentation space.A page from another space is a 404 — "That page is not in this space."
PUT …/home-docwith an empty document clears the setting rather than storing an empty one.
Editorial blocks: pull quotes, breakout media and margin notes
Four things a long read needs that a manual does not: a quote set large, a picture wider than the text, a figure on a tinted panel, and a note in the margin. Every attribute below is optional. A block that sets none of them renders exactly as it did before they existed. GET /docs/block-schema publishes each one with its allowed values, and a value outside them is a 422.
Pull quotes and cites
blockquote takes attrs:
Key | Values | What it does |
|---|---|---|
|
|
|
| text, up to 300 characters | Printed under the quote as "— cite". |
|
|
|
{
"type": "blockquote",
"attrs": { "variant": "pull", "cite": "Ada Lovelace", "align": "center" },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "The engine weaves patterns." }] }
]
}In markdown, the cite is the quote's last line, starting with an em dash (—), a horizontal bar (―) or two hyphens (--). Only that last line becomes the cite. The quoted lines above it stay the quote:
> The best interface
> is no interface.
> — Golden Krishna
{: .pull .center}The {: …} line after the quote is the block-hook line (see Block hooks). On a quote, .pull and .center are read as the quote's variant and align, not as classes. Any other class on the same line is still a hook.
A theme can make every plain quote a pull quote with components.quote: {style: "pull", size, align}. A quote whose own variant is default keeps the bar even then.
Breakout pictures and rows
image.attrs.width takes a number (a percentage of the column), a CSS length, or one of two breakout words:
wideruns past the text measure to the theme's media width (layout.widths.media, published as--reader-media). With no media width set, it runs to the page shell. It is never narrower than the column.fullruns edge to edge across the viewport, with square corners.
A breakout picture is always a <figure> (with data-width), even without a caption. Its caption stays on the text measure. columns.attrs.breakout (wide | full) does the same for a row of columns. For example, a three-up row of art can run wider than the words around it.
On a docs page a breakout runs to the article's full width, never over the navigation. To reach further, give the page metadata.layout: "full".
A column with role: "aside" (::column{role=aside sticky}) is lifted out of its row on a docs page and shown in the right-hand rail, sticky beside the article — a request example beside the prose. One per page; below 1280px it follows the article. Its width and top offset are theme.docs.aside.
Figure panels
Two more image attrs put a picture on a tinted panel, like a product screenshot on a light card:
Key | Values |
|---|---|
|
|
|
|
In markdown, write the image attrs as an attribute list directly after the image, with no space before it:
{width=wide bg=#f6f9fc pad=md}The keys are width (wide/full), bg and pad. If any token in the list is not one of these, the whole line is read as ordinary text. Rows use the directive attribute: :::columns{breakout="full"}.
Sidenotes and where footnotes go
The sidenote mark puts a note in the right margin, beside the line it belongs to. Its text is attrs.note, as for a footnote:
{
"type": "text",
"text": "margin",
"marks": [{ "type": "sidenote", "attrs": { "note": "Set at 1280px and wider." } }]
}At 1280px and wider the note hangs in the margin. Below that it is a small note under its line. Both the word and the note carry the same number. In markdown, write the note straight after the word it belongs to, the way you write a footnote:
A word in the margin>[the note] and one at the foot^[the other note].A ] inside the note is written \\].
Footnotes stay hover notes unless the space's theme says otherwise. footnotes.placement takes popover (the default, today's behaviour), margin (every footnote becomes a numbered margin note) or end (numbered in the text and collected into one list after the body, each with a link back).
A row of columns: divider, gap and one caption
A columns block takes three more look keys and a caption. All are optional; a row with none of them is the row it always was (a 1rem gap, no rule, no figure).
Key | Values |
|---|---|
|
|
| The space between columns, and between rows once they wrap: |
| The space between rows once the columns wrap, when it should differ from |
| One caption under the whole row, up to 500 characters. The row becomes a |
| The row's own room above / below it, instead of the section's one |
| Both at once; |
| How the columns line up against each other: |
|
|
A gridItem takes href: the whole cell becomes one link (a stretched link named by the cell's first heading; links inside the cell still work). ::gridItem{colSpan="4" href="/guides/start"}. A button with style: "pill" is a link pill for a row of them, and match (a path prefix) marks the pill of the page being read as current (aria-current, on a blog's or a community's front page) — :::button{label="Community" href="/community" style="pill" match="/community"}:::.
The divider is drawn between side-by-side columns only, so it goes away when the row stacks (on a phone, or on a tablet with stackOn: "md"). Under a breakout row the caption stays on the text measure, and a captioned wide or full row breaks out exactly as an uncaptioned one does. Anything else is refused with a 422 naming the key, for example divider: "red" or gap: 401.
{
"type": "columns",
"attrs": { "divider": true, "gap": "lg", "caption": "Left: the old card. Right: the new one." },
"content": [
{ "type": "column", "content": [] },
{ "type": "column", "content": [] }
]
}In markdown they ride on the directive line. divider on its own is the hairline:
:::columns{divider gap="lg" rowGap="24" caption="Left: the old card. Right: the new one."}
::column
…
::
:::Text colour
The textColor mark colours a run of words. attrs.color is required. It takes the same colours as the block color key: a theme token (muted, brand, accent, ink, bg, surface, panel) or a #hex. A token follows the theme, including dark mode. A hex is the same in both schemes. Anything else (red, url(…)) is refused with a 422.
{
"type": "text",
"text": "quieter words",
"marks": [{ "type": "textColor", "attrs": { "color": "muted" } }]
}In markdown, put the words in brackets with the colour straight after them:
Plain, then [quieter words]{color=muted}, then [a red one]{color=#e5132f}.The colour wraps any other marks, so a coloured link is written [[the docs](/docs)]{color=brand}. In the editor, select the words and use the A button in the formatting bubble. Leave the box empty to remove the colour.
Sections, video and block hooks
Three additions to the block model, all optional, all invisible on a page that does not use them. GET /docs/block-schema describes every one of them — the section keys under the section node, the video node, and the three hooks once under hooks (they apply to every node, so they are not repeated on each).
A section's look
A section block (a named slot) has always taken key and width. It also takes these, each nullable — null or absent is "as before":
Key | Value |
|---|---|
| a colour: a token ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| pixels 0–64, or |
|
|
|
|
|
|
|
|
|
|
| the pattern's ink: a colour token or |
|
|
{
"type": "section",
"attrs": {
"key": "hero",
"scheme": "dark",
"fullBleed": true,
"minHeight": "60vh",
"verticalAlign": "center",
"contentAlign": "center",
"background": {
"image": { "src": "/api/assets/…", "overlay": { "color": "#000000", "opacity": 0.45 } }
},
"padding": { "top": "xl", "bottom": "xl" }
},
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "Ship it" }]
}
]
}A scheme uses the same colours the whole page would in that mode, including your theme's paletteDark and brand — see A section in its own scheme. A section with a background or a scheme gets comfortable padding by default; set padding to override it. Background images go through the same address rule as an image block, so a library picture works on a path-mounted custom domain too.
Background video. It plays muted, looped and inline, and is never in the tab order. A reader whose system asks for reduced motion is not sent the video at all — its <source> only matches prefers-reduced-motion: no-preference — and sees the poster, which also paints the background while the video loads. Give one.
In the editor, a section's Style button opens all of the above.
The video block
{
"type": "video",
"attrs": {
"src": "/api/assets/…",
"poster": "/api/assets/…",
"caption": "The two-minute tour",
"aspectRatio": "16:9",
"controls": true,
"autoplay": false,
"muted": false,
"loop": false
}
}srcis a library path (upload an MP4 from the block, or/api/assets/…) or anhttps://URL. Anything else —http:,data:, a bare//host— is a 422. A video on another host also needs that host in the theme'smediaOrigins: videos are not proxied, and the reader's policy refuses an origin it was not told about.aspectRatiois16:9(default),4:3,1:1,9:16or21:9. A tall video stops at the height of the screen and letterboxes.autoplayalways plays muted (no browser autoplays sound), and is paused — with controls shown — for a reader who asked for reduced motion.controls: falseis only honoured on an autoplaying video; otherwise there would be no way to start it.
In markdown it is one line, and round-trips:
:::video{src="/api/assets/abc" poster="/api/assets/def" ratio="4:3" caption="Tour" autoplay loop}:::A video as a lesson. Six more attributes, all optional:
chapters—[{ "at": 90, "title": "Setup" }](seconds, up to 100), listed under the player; a click seeks. In the editor, one0:00 Titleper line.transcript— WebVTT or plain text (200,000 characters), shown under the player in a searchable panel and indexed with the page. WebVTT is also the captions track, so there is no second file to host. Plain-text lines that open with a timestamp (0:42 Now we…) seek too.transcriptOpen,speed(the 0.75×–2× row),resume(start where this viewer stopped) — unset means the course'stheme.courses.videoon a lesson, and off everywhere else.completeAt— on a course lesson, complete it once this percent has actually been played (0 = never; unset = the course'svideo.completeAt).
The player is the browser's own <video>: no third-party script or request. In markdown: chapters="0:00 Intro|1:30 Setup", transcript="<base64>", the flags speed/nospeed, resume/noresume, transcriptopen, and completeAt="90".
The quiz block (knowledge check)
{ "type": "quiz", "attrs": {
"question": "Which hook runs after every render?",
"choices": [{ "text": "useEffect", "correct": true }, { "text": "useMemo" }],
"multiple": false,
"explanation": "Effects run after the browser paints."
} }Ungraded: checked in the reader's browser, records nothing; it draws nothing until it has a question, two choices and a right one. Graded: { "quizId": "<course quiz id>" } — one of the course's lesson quizzes or module assessments, mounted where the block sits on a lesson and graded on the server (a graded check is not drawn outside a course). In markdown it is :::quiz{data="<percent-encoded JSON>"}:::. "Reveal the solution" is an expandable with "style": "solution" (untitled, it says "Reveal the solution").
Block hooks: anchor, className, hook
Every block — a paragraph, a callout, a section, a video — may carry any of three attributes inside its attrs, so customCss can reach that one block:
{
"type": "hint",
"attrs": { "style": "info", "anchor": "limits", "className": "loud", "hook": "limits" }
}renders as <div class="hint hint-info u-loud" id="limits" data-fx-block="limits">. The names, the u- prefix and how ids are kept unique are in Hooks you put on a block yourself. Values that do not match the rules are a 422, never silently changed.
A heading with no anchor gets one from its words: lowercased, accents folded (é → e), punctuation dropped, spaces as hyphens, at most 80 characters, and -2, -3 … for repeats. "Caché con ETags" is #cache-con-etags; letters with no accent to fold are kept (#политика). Headings published before accents were folded were addressed without the accented letter (#cach-con-etags); that old id still lands on the heading.
In the editor, any block at any depth takes them — the paragraph in the second column, a grid item, a card, a tab's body, a carousel slide, a modal's contents. Right-click inside the block: the menu lists that block and every block around it, innermost first ("Paragraph", "Column", "Columns"), outlining each on the canvas as you point at it, and picking one opens its Anchor / Classes / Hook fields. The same menu opens from the # in the formatting bubble for whatever is selected, and from Ctrl/⌘ + Alt + A. Shift + right-click is still the browser's own menu. A top-level block also has the # beside its drag handle, and a section has the same three fields at the bottom of its Style popover.
In markdown, any block with a directive carries them as attributes — :::hint{style="info" anchor="limits" class="loud" hook="limits"}. Every other block uses an attribute list in braces, the kramdown/Pandoc spelling: #anchor, then .class per class (up to four), then hook=name.
## Pricing {#pricing .wide hook=plans}
Our plans start at zero. {#intro .lede}
- Sign up
- Invite your team
{: #steps .tight}A heading or paragraph takes the list at the very end of its (last) line, after a space.
A list, checklist, quote, code block, table, image, divider, formula or mermaid diagram takes it on a line of its own that starts with
{:, after one blank line below the block. Leave the blank line in: without it a list's last item (or a table's last row) would read the line as its own text.
They round-trip: an export writes exactly this, and importing it gives the same hooks back. Braces in ordinary prose are left alone — the list is only read at the end of a line and only when every token in it is valid, so the set {a, b}, {#} or {#Not A Slug} stay text. If a sentence really does end in something like {#note}, write \{#note}; an export escapes it for you. Hooks on a list item or inside a table cell have no spelling in markdown, so the studio's in-place markdown edit sends a page with those to the full editor rather than saving it back without them. A section's look is not in markdown either (a section is not, in markdown — only its words are), and writing a slot's words through PUT …/sections/{key} keeps its look.
Custom HTML blocks: where their CSS can reach
The CSS of a custom HTML block is rewritten so that every rule lands inside that block. h2 { … } styles the block's own headings; :root, html and body mean the block itself. That holds inside @media, @supports, @container, @layer and @starting-style, however deeply nested, and for nested rules (&). A selector that starts with + or ~ would reach the block's siblings and is dropped. @keyframes, @font-face and @property keep their bodies but get a block-specific name — your @keyframes spin becomes the block's own, and the block's animation: spin … follows it — so a block cannot redefine an animation, a font family or a registered property of the page around it. @page is kept as written. @import, @charset, @namespace, the @layer a, b; statement and at-rules we do not know (such as @scope) are removed, and nothing in the CSS can close the <style> element it is printed into.
Embeds
An embed block frames another site inside a page. In markdown it is one line:
::embed{url="https://calendly.com/acme/intro"}Paste the page's ordinary address — the one in your browser's address bar — and the reader turns it into that provider's own embed. These are recognised:
Provider | Paste a link like |
|---|---|
YouTube |
|
Vimeo |
|
Loom |
|
Figma |
|
CodePen |
|
CodeSandbox |
|
StackBlitz |
|
Replit |
|
Google Docs | a document, spreadsheet or presentation link |
Google Maps |
|
Calendly |
|
Typeform |
|
Tally |
|
Spotify | a track, album, playlist, episode, show or artist |
SoundCloud | any track or set page |
Airtable | a shared view or form ( |
Miro |
|
Excalidraw | a shared scene ( |
X / Twitter |
|
| |
GitHub Gist |
|
Anything else is shown as a link, unless you have told us you trust the host: add its exact origin to the theme's frameOrigins (see Theming, advanced) and an embed of any URL on it is framed as written — a Stripe pricing table, a HubSpot form, a status widget, your own app.
Every frame is sandboxed with the least it needs: a video player may run its script and keep its own cookies; a booking page or a form may also submit; a gist gets no script at all. None of them may navigate your page or open a dialog over it. Frames load lazily, and send at most your page's origin as the referrer — an owner-declared host is sent none.
A gist is framed from GitHub's plain HTML render of it (….pibb) rather than the usual <script> embed, because that script writes into your page and the reader's security policy will not run third-party script there.
Embeds work the same on every address the space is published at — your own domain, a *.forumulate.com subdomain and the forumulate.com address — and in docs pages, blog posts, changelog releases, legal documents, events, a contact page's copy and a space's intro. Forum posts and replies render a known provider but never frame a frameOrigins host: you trusted that host for your pages, not as something any member can drop into a thread.
Forms
A form block puts a newsletter box, a waitlist or a lead form on a page. Type /form in the editor and pick or create a form in the block's inspector. The form itself is stored once (/orgs/{orgId}/forms), checked on the server for every submission, and can be placed on any number of pages. Responses are in Console → Forms, can be exported as CSV, and can be deleted per person for a data-protection request. Forms work without JavaScript and on every address, including custom domains mounted under a path. See Forms for the field types, consent, spam handling, notifications and the API.
A page's foot, links and comments
Unsetting a metadata key. PATCH …/pages/{pageId}/metadata takes null on EVERY key, and null removes that key from the page — the page then follows the space again, exactly as one that never set it. {"layout": null} answers 200 and hands the article width back to theme.docs.articleWidth; {"hideTitle": null}, {"template": null} and the rest work the same way. The keys you do not send are left alone. (fields keeps its own one-level-deeper rule: {"fields": {"audience": null}} clears one field; {"fields": null} clears them all.)
Metadata keys (PATCH …/pages/{pageId}/metadata) for what hangs under a page:
Key | Meaning |
|---|---|
|
|
|
|
|
|
|
|
| This page's "Edit this page" address, beating |
| The repository file the page came from. A repository sync records it; it is the |
| Up to 20, published as |
A page's own look and share card
In the docs rail
Five metadata keys decide how a page appears in the docs tree (PATCH …/pages/{pageId}/metadata):
Key | What it does |
|---|---|
| An emoji, an icon name from |
| What the tree calls the page, when that is not its title. Previous/next and breadcrumbs keep the title. |
|
|
|
|
|
|
A glyph name used to print as the word ("book") and a longer one was refused; both now draw the glyph.
Every page — and every blog post, changelog release and legal document — can override the space for itself alone, from Page look & sharing in its details rail or over the API:
Custom CSS for this page only, after the space theme. It is sanitised and scoped to the reader exactly like the space's
customCss(@importremoved, nothing can close the<style>), soh2 { … }styles this page's headings and nothing else.A partial theme — colours, palettes, fonts, colour scheme, components, widths, motion — layered over the space's. A misspelled key is refused by name.
A share image separate from the cover, which wins over the cover and the generated social card everywhere the page is shared; plus an og title, an og description and the X/Twitter card type.
JSON-LD of your own, which replaces the automatic
TechArticle/BlogPosting/Articlemarkup on that page. It must be valid JSON.
On a page these are metadata keys (PATCH …/pages/{pageId}/metadata with customCss, theme, shareImageUrl, ogTitle, ogDescription, twitterCard, jsonLd); on a post, a release or an inline legal document they go under customization. null clears one. See Theme your site → Per-page customization for every rule.
You see them while you set them. The page editor's canvas wears the page's own CSS and theme on top of the space's, and changes as you type in the details rail — before anything is saved. It is the reader's own stylesheet, aimed at the canvas alone, so h2 { … } restyles the page's headings and never the editor around them. For a post, a release or a legal document, the studio's live view (and any other preview showing that piece) swaps in your unsaved CSS and theme the same way, and puts the saved look back if you close the card without saving. A ?draft=1 or preview-link view of a page that has never been published shows its own look too.
The canvas also moves the way the reader does where the pointer is concerned: link, button, card and image hovers, hover-triggered animations and the focus ring follow the space's (or the page's) motion. Scroll reveals, page transitions, entrance animations and anything pinned to the window (a scroll-progress bar, a cursor glow, a sticky header) stay off while you edit — a paragraph that fades in as you scroll to it is not one you can type into.
Interactive blocks
Carousels, galleries with a full-screen viewer, pricing tables, testimonials, logo walls, stats, marquees, a button that opens a dialog, and a before/after slider. Type / in the editor and pick one (Media: Carousel, Gallery, Before / after, Marquee; Layout: Pricing, Testimonials, Logo wall, Stats, Button with dialog), or write the JSON below through the API.
Three promises hold for all of them:
The page says everything with no JavaScript. Every slide, every price, every quote and the final value of every stat is in the server's HTML — a crawler, a reader with scripts off and a screen reader all get the content. Behaviour (arrows, autoplay, the viewer, count-up, the slider) is added by one small script the reader fetches only on a page that has one of these blocks; a page without them loads nothing extra.
Reduced motion is honoured. A carousel does not autoplay (it offers a play button instead), stats do not count, a marquee stands still and wraps.
They wear your theme. Colours, corner radius, shadows, button styles and focus rings come from your palette and
components.*tokens. Every block has a stable hook forcustomCss([data-fx-carousel],[data-fx-pricing], … — the full list is in thefx-1hook set). Pictures load through the same image rules as an image block, so they work on a custom domain mounted under a path.
Every block also takes the three block hooks.
carousel
Slides of any blocks — pictures, text, cards. Swipe or scroll it with no script (CSS scroll snap); the dots are links to each slide. With script it gains previous/next buttons, arrow keys/Home/End on the focused row, and autoplay that pauses while the pointer is over it or focus is inside it, with its own pause button.
{
"type": "carousel",
"attrs": {
"label": "Customer stories",
"autoplay": true,
"interval": 6000,
"loop": true,
"arrows": true,
"dots": true,
"perView": 3,
"perViewMd": 2,
"perViewSm": 1,
"gap": "md"
},
"content": [{ "type": "carouselSlide", "attrs": { "label": "Acme" }, "content": ["…blocks…"] }]
}perView(1–6) is how many slides show at once on a wide screen,perViewMd(1–4) at 900px and below,perViewSm(1–3) at 640px and below.intervalis milliseconds per slide, 2000–30000 (default 5000).gapbetween slides isnone,sm,mdorlg.labelis the region's name for screen readers ("Carousel" if unset); a slide'slabelis announced after "2 of 5".It follows the ARIA carousel pattern: a named region with
aria-roledescription="carousel", each slide a group "n of N". The slide row is a polite live region while you move it yourself, and silent while it autoplays.A
carouselSlideexists only inside acarousel, and a carousel holds only slides — anything else is a 422 that says so.
gallery, and opening any picture full-screen
A gallery is a grid of image blocks (only images). By default each picture opens in a full-screen viewer: previous/next across the gallery, arrow keys, swipe on a phone, zoom (button, double click/tap, pinch, +/-/0), drag to pan while zoomed, caption and "2 of 7", Esc, a swipe down or a click outside to close, focus trapped while open and returned to the picture after. With no script a click opens the picture itself.
{
"type": "gallery",
"attrs": { "columns": 3, "gap": "md", "lightbox": true, "aspect": "square" },
"content": [{ "type": "image", "attrs": { "src": "/api/assets/…", "alt": "…", "caption": "…" } }]
}columns is 2–6 (two on a phone), gap is none, sm, md or lg, aspect crops every tile to square, 4:3, 3:2 or 16:9 (auto keeps each picture's shape), lightbox: false makes it a plain grid. Any single image block opens the same viewer with "lightbox": true — in the editor, select the picture and tick Open full-screen.
pricing
Plan cards (or a comparison table) with a toggle between billing periods — up to four, monthly/yearly being the usual two. The toggle is a group of native radio buttons that works with no script; a browser too old for CSS :has() shows every period's price instead of one.
{
"type": "pricing",
"attrs": {
"periods": [
{ "label": "Monthly", "suffix": "/mo" },
{ "label": "Yearly", "suffix": "/yr" }
],
"defaultPeriod": 0,
"currency": "$",
"layout": "cards",
"plans": [
{
"name": "Pro",
"description": "For growing teams",
"prices": [29, 290],
"priceNote": "per seat",
"features": [
{ "text": "Unlimited projects" },
{ "text": "SSO", "state": "no" },
{ "text": "Seats", "state": "text", "value": "Up to 50" }
],
"cta": { "label": "Start trial", "href": "/signup" },
"highlighted": true,
"badge": "Most popular"
}
]
}
}A price is a number (printed with
currency, thousands separated, and the period'ssuffix), a word ("Free","Custom") printed as written, ornull("—").prices[i]belongs toperiods[i].A feature's
stateisyes(a check, the default),no(a cross) ortext(itsvaluein place of the mark). Each mark is also spoken: "Included", "Not included".highlightedraises and outlines the plan and gives it a badge ("Popular" unlessbadgesays otherwise); its button is the primary style.layout: "table"is the comparison view: a row per feature (in the order they first appear), a column per plan, a check, a cross or the plan's words in each cell, and a header row that stays on screen as you scroll. On a phone the table scrolls sideways in its own box.1–6 plans, up to 40 features each.
testimonials and logoWall
{
"type": "testimonials",
"attrs": {
"layout": "grid",
"columns": 3,
"items": [
{
"quote": "It changed how our team ships.",
"author": "Ana Ruiz",
"role": "CTO, Acme",
"avatar": "/api/assets/…",
"logo": "/api/assets/…",
"rating": 5,
"href": "https://…"
}
]
}
}layout is grid (default), carousel (the carousel above, columns at a time, with optional autoplay/interval) or single (large, centred). A rating is 1–5 whole stars, spoken as "Rated 4 out of 5". columns is 1–4, and a block holds 1–24 testimonials.
{
"type": "logoWall",
"attrs": {
"title": "Trusted by",
"logos": [{ "src": "/api/assets/…", "alt": "Acme", "href": "https://acme.com" }],
"columns": 6,
"grayscale": true,
"marquee": false,
"speed": "normal",
"size": "md"
}
}A logo's alt is the company name — its only words. grayscale greys the logos until hovered or focused; marquee scrolls them sideways forever instead of a grid (see marquee below for how that behaves). columns is 2–8, a wall holds 1–40 logos, size is sm, md or lg, and speed (for marquee) is slow, normal or fast.
stats
{
"type": "stats",
"attrs": {
"layout": "row",
"columns": 4,
"animate": true,
"items": [
{ "value": 12500, "prefix": "$", "suffix": "+", "label": "Saved", "description": "…" }
]
}
}The final figure ($12,500+) is in the page's HTML. With animate (the default) the digits count up from zero once the block scrolls into view; the number a screen reader hears is the final one, said once. decimals (0–3) fixes the decimal places. layout is row (divided), grid or cards. columns is 2–6, and a block holds 1–12 figures.
marquee
Any blocks — a line of text, logos, buttons — scrolling sideways forever, in CSS alone. speed is slow, normal or fast; direction left or right; pauseOnHover (default on) also pauses while a link inside has focus; fade (default on) fades both edges; gap is none, sm, md or lg. The content appears once to screen readers and the keyboard (the repeat that makes the loop seamless is hidden from both). Under reduced motion it stops and wraps onto lines.
modal — a button that opens a dialog
{
"type": "modal",
"attrs": { "label": "Watch the demo", "title": "Demo", "style": "primary", "size": "md" },
"content": ["…blocks…"]
}A button (style primary, secondary or link) that opens a <dialog> holding any blocks — a video, a form, a table. size is sm, md, lg or full. It uses the browser's own modal dialog: focus moves in and is trapped, Esc closes it, and focus returns to the button. In browsers with invoker commands it opens with no script at all; elsewhere the reader's script opens it. The dialog's words are in the page's HTML either way. It is a block of its own rather than an option on button, because a button holds no blocks.
hero and person
The two landing-page primitives. A hero is the band a page opens with — the words, the buttons and a picture, laid out properly in one block instead of six; put it in a section for what is behind it (a colour, a gradient, a picture, a video). A person is one member of a team grid: put several in a grid.
{
"type": "hero",
"attrs": {
"eyebrow": "New",
"title": "Ship docs your users actually read",
"level": 1,
"sub": "One sentence on who it is for.",
"actions": [
{ "label": "Get started", "href": "/signup" },
{ "label": "Talk to us", "href": "/contact", "style": "secondary" }
],
"note": "No credit card required",
"media": { "kind": "image", "src": "/api/assets/…", "alt": "The editor" },
"layout": "split",
"mediaSide": "end",
"size": "lg"
}
}layout is center (default: everything centred, the picture under the buttons), split (words beside the picture from 900px up; on a phone the words come first whichever side the picture takes — mediaSide start or end) or below (words above a wide picture). size is the headline's scale: md, lg (default) or xl. level is 1 (default — the hero's headline is the page's; hide the page title with hideTitle so there is one H1) or 2. Up to 3 actions; unset style makes the first primary and the rest secondary. media is a picture (kind: "image") or a muted, looping video (kind: "video", src a library path or https URL, optional poster) that is never sent to a reader who asked for reduced motion. A picture carries its stored size, so the hero reserves its box before it loads.
{
"type": "person",
"attrs": {
"name": "Ada Lovelace",
"role": "Head of Engineering",
"photo": "/api/assets/…",
"bio": "Writes the first program.",
"links": [{ "label": "LinkedIn", "href": "https://…" }],
"layout": "card",
"photoShape": "circle"
}
}layout is card (default: photo above, centred) or row (photo beside the words); photoShape is circle (default), rounded or square. With no photo the person's initials stand in, on a disc tinted with the brand colour. photoAlt defaults to the name. The name is not a heading, so a grid of twelve does not put twelve names in the page's outline. Up to 6 links.
In markdown both travel as one encoded directive, :::hero{data="…"}::: and :::person{data="…"}::: — export one to get it.
officeMap
Places — offices, venues, stores — each drawn as a card with a small street map under a pin: a careers page's "Where we work", a contact page's offices.
{
"type": "officeMap",
"attrs": {
"columns": 3,
"zoom": 15,
"aspect": "wide",
"places": [
{
"name": "Berlin",
"address": "Torstraße 1, 10119 Berlin",
"lat": 52.5297,
"lng": 13.4013,
"note": "Hybrid, three days a week",
"href": "/jobs/…?location=Berlin"
}
]
}
}A place is its coordinates (lat −85.05…85.05, lng −180…180): nothing is geocoded. In the editor, paste what any map's "share" gives — 52.52, 13.405, an openstreetmap.org link or a Google Maps @lat,lng link — into Paste coordinates. zoom is 2 (a continent) to 18 (a building), default 15, and a place may set its own. aspect is wide (2:1, default), landscape (4:3) or square; showMap: false keeps the cards without maps. Up to 24 places; columns 1–4 (default 3), two to a row on a tablet, one on a phone.
The maps are OpenStreetMap's, but a reader's browser never asks OpenStreetMap for anything: each tile is fetched once by our server through the reader's image proxy and served from our own CDN, and the page reserves each map's box before a tile arrives. Where there is no proxy (a feed, an email, the editor's preview) the card shows the box and pin without tiles. The required "© OpenStreetMap contributors" credit and an "Open map" link are on every map.
In markdown it travels as one encoded directive, :::offices{data="…"}:::.
The section library
Whole bands — "Hero – split", "Feature grid – 3 up", "Alternating rows", "Bento – 5 cells", "Pricing – 3 tiers", "FAQ", "Team grid", "Call to action", "Newsletter" and more — insert from the slash menu (the Sections group), the block palette, and the studio's + beside any block. Each is ordinary blocks in a section with role: "layout", so every part of it is edited like the rest of the page. An agent gets the same list, as block JSON ready to splice into a page's content, from GET /section-library (filter with ?family=hero|features|proof|pricing|company|conversion) or one entry from GET /section-library/{id}. The section library gallery shows every one, with its id, a picture and its JSON.
imageCompare — before and after
{
"type": "imageCompare",
"attrs": {
"before": "/api/assets/…",
"after": "/api/assets/…",
"beforeAlt": "The dashboard in 2025",
"afterAlt": "The dashboard today",
"beforeLabel": "Before",
"afterLabel": "After",
"start": 50,
"caption": "…"
}
}Two pictures, one over the other, and a divider. With no script it shows both at the start split (percent from the left). With script the divider follows a drag anywhere on the picture, and a native slider takes the keyboard: Tab to it, arrows, Home, End — spoken as "40% before".
Interactive blocks in markdown
Every one round-trips. The containers are fences around ordinary markdown, so their words survive any renderer:
:::carousel{label="Tour" autoplay interval="6000" loop perView="3"}
::slide{label="Ship"}

::
:::
:::gallery{columns="3" aspect="square"}


:::
:::lightbox

:::
:::marquee{speed="slow" direction="right" nofade}
Words that scroll
:::
:::modal{label="Watch the demo" title="Demo" size="lg"}
Anything here.
:::
:::compare{before="/api/assets/b" after="/api/assets/a" beforeLabel="Old" start="40"}:::noarrows, nodots, nolightbox, nopause and nofade turn off what is on by default. Pricing, testimonials, logos and stats are tables of facts with no markdown spelling, so — like a form — each is one line with its attributes encoded: :::pricing{data="…"}:::, :::testimonials{data="…"}:::, :::logos{data="…"}:::, :::stats{data="…"}:::. A payload edited into something invalid is dropped on import rather than failing the page.
Code blocks and code groups
A codeBlock is { type: "codeBlock", attrs, content: [{ type: "text", text }] }. language colours it. Every other attr is optional, and a block with none of them renders exactly as a plain block always has:
Attr | Value | Does |
|---|---|---|
| string | A filename caption bar (in a code group: the tab) |
|
| A numbered gutter, from 1 or from that number |
|
| Highlighted lines |
| same | Inserted (green) / deleted (red) lines |
|
| A |
| same | Every other line dims until the reader hovers or tabs to the block |
|
| Those words marked wherever they occur |
| 3–500 | Clamped with a "Show all N lines" button |
| same as | Those lines folded behind "N collapsed lines" |
|
| Wrap long lines, or never, whatever the theme says |
|
| A terminal's dot bar, the title as an editor tab, or opt out of the theme's terminal bars |
| https URL | The code is fetched from there when the draft is saved (see below) |
|
| Keep only those lines |
|
| For |
|
| For TypeScript and JavaScript: type hovers, |
Line ranges are block-relative: line 1 is the first line of the block, whatever lineNumbers starts the gutter at.
twoslash. For ts, tsx, js, jsx, mts and cts blocks. When the page is published, the server checks the block with the TypeScript compiler and stores the results on the published block. Readers' pages never run a compiler or contact a third party. The reader shows:
the type of every name when it is hovered or focused. The block is one tab stop, and the arrow keys move between names.
// ^?answers. A line containing only// ^?is hidden, and the type at the caret's column on the line above is shown under that line. It is never copied by the copy button.compiler errors, with a wavy underline and their message on hover.
// ---cut---. That line and every line above it are checked but not shown, so setup code can stay out of the sample.
The block is checked as one file with the standard library (ES2022 and the DOM), in strict mode, without npm packages. A "cannot find module" error from an import is therefore not shown, and names from a package have no type. The results are computed again on every publish. Each page can have up to 12 twoslash blocks of up to 20,000 characters each. In Markdown, add the flag to the fence: ```ts twoslash. In the editor, it is the Type hovers switch in a TypeScript block's options.
language: "ansi" is terminal output: its colour escapes become colours (on the server, so they show with JavaScript off) and every other escape sequence is dropped.
src. Fetched at save, not at publish — publishing ships what was reviewed — with a 5-second timeout, 200 KB, ten blocks per save, through the same guard as every outbound URL. A github.com/…/blob/… URL is read from raw.githubusercontent.com, and a #L10-L40 permalink is the slice when srcLines is unset. The server stamps srcFetchedAt; send srcFetchedAt: null (the editor's Refresh from source) to fetch again. A failed fetch keeps the code the block had and says so on X-Field-Warnings.
preview runs no script: the markup goes through the same sanitiser as a custom HTML block, and css is scoped to the preview. pattern is stripes, dots or grid. For code a reader can run, use the playground block (below).
A code group shows the same sample in several languages, as tabs in one code header:
{
"type": "codeGroup",
"attrs": { "picker": "dropdown", "syncKey": "pm", "title": "Install" },
"content": [
{
"type": "codeBlock",
"attrs": { "language": "bash", "title": "npm" },
"content": [{ "type": "text", "text": "npm i postwick" }]
},
{
"type": "codeBlock",
"attrs": { "language": "bash", "title": "yarn" },
"content": [{ "type": "text", "text": "yarn add postwick" }]
}
]
}It holds 1–12 code blocks and nothing else. A child's tab label is its title, else its language's name, else "Tab N". picker is tabs (default) or dropdown; syncKey (letters, digits, dashes) keeps every group with the same key on the same tab, and a group also follows ordinary tabs with the same titles and remembers the reader's pick. With JavaScript off the first sample shows. In the editor, Code group in the slash menu inserts one; a tabs block whose every tab holds a single code block offers Convert to code group in the code block's bubble. A stored tabs block is never converted for you.
Motion blocks: floating layers, sticky stories, code demos
Three blocks for pages that are art-directed rather than only written: a picture or a giant word that floats over its section and drifts as you scroll, a picture that stays put while the steps beside it scroll past, and a code demo (which does not run yet — see below). Type / and pick Floating layer (Media), Sticky story (Layout) or Code demo (Advanced), or write the JSON below.
For scroll-linked movement of blocks you already have — a hero picture that sinks, a caption that fades as it leaves, a progress bar — name the block with its hook attribute and use the theme's motion.scroll.
Reduced motion turns every drift off, whatever the theme says, and a page with no JavaScript and no scroll timelines is the natural page: every layer where you put it, every step and picture of a story in order.
layer — a picture or a word floating over its section
{
"type": "layer",
"attrs": {
"kind": "text",
"text": "Typography",
"x": 6,
"y": 10,
"size": 14,
"font": "heading",
"weight": 800,
"color": "brand",
"z": 0,
"rate": 0.4,
"rotate": -4,
"opacity": 0.9,
"blend": "multiply",
"hideOn": ["phone"]
}
}Attribute | Values | Default |
|---|---|---|
|
| required |
| Where its top-left corner sits in its section, as percentages ( |
|
| A picture's width as a percentage of its section (a word sizes itself) | its own |
| A word's size as a percentage of the screen width, 1–40 (never under 1rem) |
|
|
|
|
| A colour token ( | the text's |
|
|
|
| Parallax as the page scrolls, −1 to 1: |
|
| Degrees, ±180 |
|
| 0–1 |
|
| How it mixes with what is behind it: |
|
| Any of | shown |
Inside a full-bleed section, x and width are measured across the viewport, not the column, and the layer is clipped at the screen's edges rather than the column's.
Put layers inside a section: the section becomes their frame (positioned, its own stacking context, and clipped, so a layer pushed past an edge never widens the page). A layer is decoration — hidden from screen readers, never clickable, not part of search — so never put words in one that a reader needs. In the editor it is shown in place, not floating, so you can edit it.
story — a picture that sticks while the steps scroll past
{
"type": "story",
"attrs": { "label": "The firing", "side": "end", "mediaWidth": "wide", "top": 96 },
"content": [
{
"type": "storyStep",
"attrs": { "media": "/api/assets/…", "alt": "The kiln at dawn", "caption": "Day one" },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Loading the kiln." }] }
]
},
{
"type": "storyStep",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Twelve hours later." }] }
]
}
]
}Attribute ( | Values | Default |
|---|---|---|
| The region's name for screen readers | "Story" |
| Which side the picture is on: |
|
|
|
|
| px from the top of the screen the picture sticks at, 0–240 |
|
A story holds 1–12 storySteps, and a step holds ordinary blocks. A step's media (with alt and caption) is the picture shown while that step is the one being read; a step without one keeps the picture before it. On a phone the picture sticks across the top and the steps scroll up over it.
With no script, the story is every step and every picture, in order, the picture column sticky in CSS alone. The reader's script (fetched only on a page with one) stacks the pictures into one frame and changes it as each step reaches the middle of the screen; under reduced motion the change is instant rather than a crossfade.
playground — a runnable code demo
{
"type": "playground",
"attrs": {
"title": "Easing, side by side",
"html": "<div class=\"box\"></div>",
"css": ".box { transition: transform .3s ease-out }",
"js": "console.log('ready')",
"layout": "split",
"editable": true,
"height": 320,
"fallbackSrc": "/api/assets/…",
"fallbackAlt": "Two boxes, one easing out",
"fallbackCaption": "Ease-out on the left"
}
}A playground shows its HTML, CSS and JavaScript in editors next to a live preview. Readers can edit the code (unless editable is false), run it again, reset it to your version, and read what it logs to the console.
Attr | Meaning |
|---|---|
| The sources, up to 20,000 characters each. Only the sources you set are shown. |
| The order of the editors, for example |
|
|
|
|
| The preview's height in pixels, 120 to 1200 (default 320). The space is reserved before the demo loads, so the page does not move. |
| A picture shown in the preview until the demo runs, and instead of the playground when demos cannot run. |
Where the code runs. Demo code never runs on your site's address or on ours. It runs in an isolated frame on a separate sandbox domain that has no cookies, no session and no network access. The demo cannot call APIs, load third-party scripts or send data anywhere. Images from https: URLs in your code still load. The demo runs when it scrolls into view.
When demos cannot run. If the platform has no sandbox domain configured, the editor says so on the block. Readers then see only the fallback picture, or nothing if there is none, and the code is not put into the page. Your code is kept, and the playground starts working as soon as a sandbox is available.
Motion blocks in markdown
:::layer{kind="text" text="Typography" x="6" y="10" size="14" z="0" rate="0.4" hideOn="phone"}:::
:::story{side="end" mediaWidth="wide" top="96"}
::scene{media="/api/assets/a1" alt="The kiln at dawn" caption="Day one"}
Loading the kiln.
::
::scene
Twelve hours later.
::
:::
:::playground{title="Easing" layout="split" fallback="/api/assets/p1" fallbackAlt="Two boxes"}
…one fenced code block per source, tagged html, css or js…
:::A story's steps are ::scene (::step is the stepper's). A playground's sources are ordinary fenced code blocks tagged html, css and js; anything else inside the fence is kept after the block. noedit sets editable: false. A layer whose values are out of range is dropped on import rather than failing the page.
Front-page blocks
Three blocks bring a space's live content into a document. They are what a space front page is built from, and they work in its sidebar too. None has children — a feed's content is data, fetched when the page is read, never stored in the document. Each takes the three block hooks.
spaceFeed
{
"type": "spaceFeed",
"attrs": {
"space": "self",
"filter": { "tags": ["release"], "featured": true },
"sort": "latest",
"limit": 6,
"layout": "grid",
"columns": { "sm": 1, "md": 2, "lg": 3 },
"heading": "Featured",
"moreLink": { "label": "All posts" }
}
}Every attribute is optional and nullable.
Attribute | Value |
|---|---|
|
|
|
|
|
|
|
|
| 1–50, default 6. Ignored when |
|
|
|
|
| up to 16 card field names; unset = the space shape's summary fields |
|
|
| 1–50, default 10 (or |
| ≤ 300 characters, shown when nothing matches |
| ≤ 200 characters, an |
|
|
| the composed card — |
| a layout for phones, when it differs |
| a |
|
|
|
|
|
|
| 0–50 items to skip first |
|
|
|
|
|
|
The filter keys: category (a community category slug, ≤ 120), tags (up to 10, each ≤ 60) with tagMatch any (default) or all, pinned and featured (booleans — false keeps only the rest), exclude (up to 50 of pinned, featured or an item id), author (a display name), lane (a roadmap status), version (a changelog version), when (upcoming or past, for events) and section (a docs collection, FAQ section or careers department). A key the target's type does not honour is ignored; the per-type table is in Space front pages.
Card fields per type: blog title, standfirst, cover, author, date, readTime, tags, series, replies; community title, author, replies, views, date, category, tags, excerpt; changelog title, summary, date, version, tags; roadmap title, status, votes, excerpt; events title, date, location, excerpt; careers title, location, employment, department; FAQ and courses title, section; docs and custom title, section, excerpt.
A feed whose target space this reader may not see — private, gated, deleted or in another organization — renders nothing at all, heading included.
spaceCategories
{
"type": "spaceCategories",
"attrs": { "layout": "tiles", "fields": ["color", "count", "topTopics"], "topTopics": 3 }
}Attribute | Value |
|---|---|
|
|
|
|
|
|
| any of |
| 0–5, how many top topics a tile lists when the |
| 1–50 categories |
| a category slug: only its subcategories |
| ≤ 200 characters |
| ≤ 300 characters. With no categories and no |
|
|
spaceBand
{ "type": "spaceBand", "attrs": { "band": "search" } }band is required: one of the space type's own front-door bands (intro, search, subscribe, lanes, board, roles, tags, …), rendered as the reader always drew it. The names each type offers, with the options each takes, are served by GET /space-types/{type}/presets (frontPage.bands, frontPage.bandOptions) and listed in Space front pages. A band the type does not have renders nothing.
Band | Options beside |
|---|---|
|
|
|
|
|
|
An option on a band that does not take it is a 422 naming its path.
Front-page blocks in markdown
:::feed{space="self" layout="magazine" sort="top" period="week" limit="6"}:::
:::feed{category="announcements" pinned tags="release,beta" heading="News"}:::
:::categories{layout="tiles" fields="color,count,topTopics" topTopics="3"}:::
:::band{name="search"}:::Each is one self-closing directive. cols, colsMd and colsSm are the desktop, tablet and phone columns; fields and tags are comma lists; more and moreHref are the moreLink (moreAt, moreStyle); empty is emptyText; and the bare flags paginate, controls, dedupe, pinned and featured mean true (paginate="more" is the Show-more button). card, lead and tabs are JSON in one attribute; a band's options ride on its directive (:::band{name="tags" style="tabs" rss="icon"}:::). A directive that no longer validates is dropped on import, never stored.
API reference blocks
The openapi block's own attributes
An operation block ({ "type": "openapi", "attrs": { "operation": … } }) takes two optional attributes that override the space's theme.docs.api for that one block:
Attr | Values | What it does |
|---|---|---|
|
|
|
|
| Parameters and fields as a table, or as rows with nested children behind a disclosure. |
metadata.api: "POST /v1/refunds" builds the block for you from a spec registered in the space — see Importing.
walkthrough — code beside the steps that explain it
One piece of code in a sticky pane, and a list of steps. Each step names the lines it is about; scrolling to a step (or focusing or clicking it) lights those lines. Below 1024px it is one column, and each step shows its line range.
{
"type": "walkthrough",
"attrs": {
"language": "ts",
"title": "server.ts",
"code": "const client = createClient();\nawait client.ping();"
},
"content": [
{
"type": "walkthroughStep",
"attrs": { "lines": "1", "title": "Create the client" },
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "One client per process." }] }
]
}
]
}lines uses a code block's highlight syntax: 3, 3-7, 3-7,9. A walkthrough needs at least one step, and a step lives only inside a walkthrough. In the editor it is Code walkthrough in the slash menu.
Docs blocks (wave D)
Every attribute below is optional; unset draws the block exactly as before. The live schema (GET /docs/block-schema) lists each one, and now also a number's min/max and a description where the schema has one.
Block | New attributes |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
New blocks:
Block | Shape |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
New marks: mathInline (the text is the LaTeX), badge (attrs.variant), icon (attrs.name, a glyph; the text is its label). The editor now keeps kbd marks.
A section nested in a column, a grid cell or another section no longer needs a key: it is given band-<n> (and role: "layout") and the write says so on X-Field-Warnings. A top-level section still needs its key.
Round 3 block attributes
Every key below is optional and unset draws the block exactly as before — except the ones marked fix, which change what an existing page shows. The live schema (GET /docs/block-schema) lists each one; the markdown spelling of each is in Markdown in Forumulate › Round 3.
Block | Attribute | Value |
|---|---|---|
|
| fix. Unset (or |
|
|
|
|
|
|
|
|
|
|
| as before, provider ids ( |
|
|
|
|
| 1–40: the body is folded to that many lines, faded, behind "Show more" |
|
| an icon before the heading's words (glyph name, emoji or image address); |
|
| 0.9–2, unitless: a 56 px hero set at |
|
| fix (it was refused). |
|
| the cell's icon, above its content; |
|
| a dotted or squared backdrop — see A section's look |
Fix: grid cells now stretch to their row's height, so a row of tiles is one height, and a cell's align (center, bottom) places its content inside the full-height cell instead of shrinking the cell.
Fix: a code fence marked text (or plaintext, txt, plain, none) no longer shows a "TEXT" language chip.
{
"type": "gridItem",
"attrs": {
"colSpan": 1,
"textAlign": "center",
"icon": "rocket",
"iconStyle": "tile",
"border": true
},
"content": [
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Quickstart" }]
}
]
}{
"type": "prompt",
"attrs": {
"text": "Add authentication to my Next.js app",
"variant": "pill",
"align": "center",
"targets": [{ "icon": "https://example.com/cursor.svg", "label": "Cursor" }, "claude"]
}
}A docs space can also set the look its prompts take when they name none — theme.docs.blocks.prompt.variant; see Theme a documentation space › Blocks. A prompt that names its own variant keeps it.
On a page with metadata.hideTitle, the first heading is the page's hero: the reader marks it data-hero-heading, and the on-this-page rail leaves it out.
In the editor, a grid cell's Style popover has the text alignment, the icon and its style; every other key above is kept when you edit the page.
Round 4 block attributes
Each attribute mirrors a theme key of the same name and wins over it for this block alone (block > page metadata.themePatch > space theme > look > today — see Theme components › Theme, page or block), so two blocks on one page can look different. Every one is optional and unset draws the block exactly as before. The live schema (GET /docs/block-schema) lists each, with its values and bounds; the markdown spelling is in Markdown in Forumulate › Round 4.
Block | Attribute | Value |
|---|---|---|
|
|
|
|
|
|
|
| px, 12–64 and 32–96 |
|
|
|
|
|
|
|
|
|
|
| one |
|
| px 0–24 under the title |
|
|
|
|
|
|
|
|
|
|
|
|
|
| as on a code block, for the whole group |
|
|
|
|
|
|
|
|
|
|
|
|
|
| up to six |
|
|
|
|
|
|
|
|
|
|
|
|
|
| a glyph name drawn before the label |
|
|
|
|
|
|
|
| 1–6: on a phone a |
|
|
|
|
|
|
|
| one space value as before, or |
|
| a colour as before, or |
On a wide or full docs page a row of columns now spans the article, as a layout grid does.
Fix: a one-line code block with no title puts its copy glyph inside the line, at the end, at every width — no band above it.
Fix: four cards with descriptions and no columns go four across only on a track of 1000px or more (two by two below it); four title-only cards keep the 720px rule.
Fix: a wide table on a phone fades its end edge while it can still scroll, and an inline code chip cut by a line break keeps its corners.
{
"type": "codeGroup",
"attrs": { "tabStyle": "pill", "look": "inset", "lineNumbers": true },
"content": [
{
"type": "codeBlock",
"attrs": { "language": "bash", "title": "npm" },
"content": [{ "type": "text", "text": "npm i @acme/sdk" }]
},
{
"type": "codeBlock",
"attrs": { "language": "bash", "title": "pnpm" },
"content": [{ "type": "text", "text": "pnpm add @acme/sdk" }]
}
]
}{
"type": "section",
"attrs": {
"key": "hero",
"pattern": "dots",
"patternColor": { "from": "#f97316", "to": "#8b5cf6", "angle": 120 },
"patternFade": "radial"
},
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Payments, everywhere." }] }
]
}In the editor, a card grid has a Look button over it with every cards attribute above; a grid cell's Style popover has the icon's tint. Every other attribute is kept when you edit the page.
Round 5 block attributes
The same contract as round 4: each attribute mirrors a theme key of the same name and wins over it for this block alone; every one is optional and unset draws the block as before. Theme keys and the DOCS layer's defaults: Theme components › Round 5. Markdown: Markdown in Forumulate › Round 5.
Block | Attribute | Value |
|---|---|---|
|
| px 160–480: the narrowest a card gets — the grid shows min( |
|
| a space token or px, or |
|
| boolean: a |
|
| px 0–120: a |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 0–6: the "Open in …" links kept in the row from 641px; the rest are under More |
|
|
|
The theme's docs.blocks.prompt.icon now draws on every prompt that names no icon of its own.
{
"type": "cards",
"attrs": {
"columns": 3,
"minWidth": 260,
"gap": { "lg": 16, "sm": 12 },
"variant": "panel",
"divider": false,
"iconBand": 88
},
"content": [
{
"type": "card",
"attrs": { "title": "Deploy", "icon": "rocket", "description": "Ship from the CLI." }
}
]
}{
"type": "prompt",
"attrs": {
"text": "Add auth to my app",
"variant": "compact",
"openInDesktop": 2,
"collapseStyle": "pill",
"labelCase": "none"
}
}In the editor, a card grid's Look button has min width, gap, row gap, panel divider and icon band; the other attributes are kept when you edit.
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 clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Ai
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the review in a space (pages AI) |
| |
| Create the rewrite in a space (pages) |
| |
| Create the translate in a space (pages AI) |
| |
| Create the ask in a space (docs) |
| |
| Create the stream in a space (docs) |
| |
| Create the feedback in a space (docs ask) |
| |
| Create the agent in a space |
|
Block Schema
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the block schema |
|
Change Request
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the agent review in a space |
| |
| Create the agent run in a space |
| |
| List change requests in a space |
| |
| Create a change request in a space |
| |
| List merge rules in a space |
| |
| Replace merge rules in a space |
| |
| List merge blockers in a space |
| |
| List conflicts in a space |
| |
| Get the eligibility in a space |
| |
| Get the change request (spaces) |
| |
| List pages in a space (orgs change requests) |
| |
| Create the request review in a space |
| |
| Approve the change request |
| |
| Merge the change request |
| |
| Create the rebase in a space |
| |
| List reviewers in a space |
| |
| Create a reviewer in a space |
| |
| Delete the reviewer |
| |
| Create the review in a space (change requests) |
| |
| Close the change request |
| |
| List comments in a space (change requests) |
| |
| Create a comment in a space (change requests) |
|
Collection
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List collections in a space |
| |
| Create a collection in a space |
| |
| Get the collection |
| |
| Update the collection |
| |
| Delete the collection |
| |
| Reorder the collection |
| |
| List pages in a space (orgs collections) |
| |
| Create a page in a space |
| |
| Create the order in a space |
|
Content Rule
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List content rules in an organization |
| |
| Create a content rule in an organization |
| |
| Delete the content rule |
|
Discovery
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List analytics in a space (orgs docs) |
| |
| Get the feedback in a space |
| |
| File of feedback |
| |
| File of llms full (docs) |
| |
| List tags in a space (docs) |
| |
| Get the tag (spaces docs) |
| |
| File of skill (docs) |
| |
| File of llms (orgs spaces docs) |
| |
| Get the markdown in a space |
| |
| Get the markdown in a space (draft) |
| |
| Create a check link in a space |
| |
| List empty sections in a space |
| |
| List orphans in a space |
| |
| List backlinks in a space |
| |
| Get the stale in a space |
|
Form Template
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List form templates in a space |
| |
| Create a form template in a space |
| |
| Get the form template |
| |
| Update the form template |
| |
| Delete the form template |
| |
| Attach the form template |
|
Library
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the reusable content in a space |
| |
| Create the reusable content in a space |
| |
| Update the reusable content |
| |
| Delete the reusable content |
| |
| List variables in a space |
| |
| Replace variables in a space |
| |
| Delete the variable (spaces) |
|
Org Library
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List reusables in an organization |
| |
| Create a reusable in an organization |
| |
| Delete the reusable |
| |
| List variables in an organization |
| |
| Replace variables in an organization |
| |
| Delete the variable (library) |
|
Page
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the page (orgs spaces) |
| |
| Update the page |
| |
| Delete the page |
| |
| Get the metadata in a space |
| |
| Update the metadata in a space |
| |
| Reorder the page |
| |
| List pages in a space (orgs) |
| |
| Get the inventory in a space |
| |
| Create the bulk delete in a space |
| |
| Create the bulk move in a space |
| |
| Duplicate the page |
| |
| List trash pages in a space |
| |
| Create the empty trash in a space |
| |
| Restore the page |
| |
| Unpublish the page |
| |
| Purge the page |
|
Page Template
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List page templates in a space |
| |
| Create a page template in a space |
| |
| Delete the page template |
| |
| Create the new page in a space |
|
Public export
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Export the space |
|
Public llms full.txt
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| File of llms full (public reader) |
|
Public llms.txt
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| File of llms (public reader orgs spaces) |
|
Public pages
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the PDF in a space (pages) |
| |
| Get the page (public reader orgs spaces markdown) |
| |
| Get the version in a space |
| |
| Get the page (public reader orgs spaces) |
| |
| Create the view in a space (pages) |
|
Public pdf
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the PDF in a space |
|
Public skill.md
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| File of skill (public reader) |
|
Public Interaction
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the ask in a space (public reader) |
| |
| Create the stream in a space (public reader) |
| |
| Create the feedback in a space (public reader ask) |
| |
| List comments in a space (public reader pages) |
| |
| Create a comment in a space (public reader pages) |
| |
| Create the react in a space (public reader comments) |
| |
| Render the ext |
| |
| Create the action in a space |
| |
| Create the feedback in a space (public reader pages) |
| |
| Create a search event in a space |
|
Public Relation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List relations in an organization |
|
Section Library
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the section library |
| |
| Get a schematic picture of one section |
| |
| Get one entry of the section library |
|
Shape
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the shape in a space |
| |
| Replace the space's shape override |
| |
| Merge a patch into the space's shape override |
| |
| Replace the default kind in a space |
| |
| Replace the kind |
| |
| Delete the kind |
| |
| Create the preset |
|
Space Settings discovery
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the discovery in a space |
| |
| Replace the discovery in a space |
|
Space Settings glossary
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the glossary in a space |
| |
| Replace the glossary in a space |
|
Space Settings home doc
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the home doc in a space |
| |
| Replace the home doc in a space |
|
Space Settings home page
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the home page in a space |
| |
| Replace the home page in a space |
|
Space Settings redirects
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List redirects in a space |
| |
| Replace redirects in a space |
|
Space Snapshot
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List snapshots in a space |
| |
| Create a snapshot in a space |
| |
| Restore the snapshot |
| |
| Delete the snapshot |
|
Space Template
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List space templates in an organization |
| |
| Update the space template |
| |
| Delete the space template |
|
Space Template Actions
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the save as template in a space |
| |
| Apply template in a space |
|
Space Types
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List space types |
| |
| List presets |
| |
| List slots |
| |
| Get the space type |
| |
| List presets (for a space type) |
| |
| List slots (for a space type) |
|
Translation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List locales in a space |
| |
| Create a locale in a space |
| |
| Update the locale (spaces docs) |
| |
| Delete the locale (spaces docs) |
|
Translation Override Public
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The interface strings you can override |
| |
| Get the translation (public reader i18n) |
|
Version
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List versions in a space |
| |
| Create a version in a space |
| |
| Update the version |
| |
| Delete the version |
| |
| Create the make default in a space |
|
Versioning
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the versioning in a space |
| |
| Replace the versioning in a space |
|