Creating a space
POST /orgs/{orgId}/spaces
{
"name": "Guides",
"slug": "guides",
"type": "DOCS",
"visibility": "PUBLIC"
}That is the whole minimum. The rest of this page is the four optional fields that behave in ways the field names do not suggest.
seed
{ "name": "Guides", "slug": "guides", "type": "DOCS", "seed": true }A boolean. Its default depends on who is asking:
Caller | Default |
|---|---|
The console, or any session-authenticated call |
|
A personal access token |
|
That asymmetry is deliberate. Bootstrapping a site through the API means deleting a starter page once per space before writing anything, and an agent that is about to write the space does not want ours first. A person in the console still gets it, because an empty space is a worse first screen than an example one.
What seed: true actually creates: one collection, named "Pages", marked as the default. Nothing else. No landing page, no example content, no cards, no "Getting started" stub.
It used to write a landing page with cards and two example pages. That was our words, in your voice, published at your address before you had typed anything. What a space needs at creation is a place for pages to live; what goes in them is yours.
So it does not vary by space type, beyond this: a space whose pieces are pages gets the collection, and a space whose pieces are not gets nothing.
Space types |
|
|---|---|
| Creates the default "Pages" collection |
| Nothing — their pieces are topics |
| Nothing — its pieces are events |
| Nothing — its pieces are monitors |
Idempotence does not really arise: seed runs inside the create transaction and each create makes a new space. A duplicate slug is refused first with a 409 space.slug_taken; a reserved slug with 409 space.slug_reserved.
space.slug_taken names the holder in error.details: heldBy (the space's id) and state (live or trashed, with deletedAt for the trash). A trashed space keeps its name until it is purged — the space list hides it, so the message says so and how to free it: restore it (POST …/spaces/{id}/restore) or delete it for good (DELETE …/spaces/{id}/purge). Create, duplicate and rename all answer this way. Two creates with different slugs make two spaces — POST is not idempotent.
seed and shape do not interact. Neither reads the other.
If you are an agent creating a space to write into: leave seed alone. The default for a token is already false, and you will create your own collection with POST …/collections.
shape
{
"index": "landing" | "list" | "board" | "single",
"order": "manual" | "chronological" | "activity" | "votes" | "status",
"nav": "tree" | "categories" | "tags" | "sections" | "none",
"whoCreates": "staff" | "members" | "anyone"
}All four optional. Accepted only for type: "CUSTOM". For every other type it is a 422:
// POST /orgs/{orgId}/spaces with type DOCS and a shape
{
"error": {
"code": "validation.failed",
"message": "A documentation space is laid out by a preset — one of: guide, reference, minimal, landing.",
},
}The message names the presets for the type you asked for, so the refusal tells you what to send instead.
Why
A layout is chosen, never assembled. A space's shape is a patch over the type's declared shape, so improvements to a type's declaration keep reaching every space that never overrode them. Letting a named type be assembled key-by-key at creation would freeze an arrangement nobody designed, and turn the space types into a combinatorial space with no owner.
So a named type takes a preset from its own registry, which is also resolved at read — meaning a better default reaches every space that did not choose otherwise. CUSTOM is the exception because "choose your index, your navigation and who may post" is the entire point of a custom space.
An empty object passes. The guard requires at least one key, so {"shape": {}} is not an error — it just does nothing.
Setting a shape after creation
PUT or PATCH /orgs/{orgId}/spaces/{spaceId}/shape has no type restriction. Every type can be reshaped once it exists; it is only the free-form shape at creation that is reserved to CUSTOM.
Use PATCH. PUT 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.
preset
{ "name": "Guides", "slug": "guides", "type": "DOCS", "preset": "reference" }The named layout for the type. GET /space-types/{type}/presets lists them; DOCS has guide, reference, minimal and landing.
An unrecognised preset for the type is a 422 — No such layout for a documentation space. One of: ….
preset is stored only when you choose one explicitly, which is what lets an unspecified space keep following the type's default as that default improves.
Note that preset decides which landing mechanism applies: landing uses home-doc, everything else uses home-page. See Authoring.
visibility
PUBLIC or PRIVATE.
Which site the space joins is siteId on the create:
siteId: "<site id>"— joins that site (one ofGET /orgs/{orgId}/sites; any other id is a 422) and wears its header, footer and theme. Works for aPRIVATEspace too: you asked.siteId: null— joins no site. The space is published at its own address and wears its owntheme.chrome.unset — depends on your
Forumulate-Version. From2026-12-01it joins no site, andwarningsnames the site you could have joined. Before that, and with no version, aPUBLICspace joins your organization's oldest site, as it always has, andwarningsnow says which:"warnings": ["siteId: not set, so this public space joined the organization's oldest site \"main\" (…) and wears its header, footer and theme; send siteId: null to join none, or a site id to choose (from 2026-12-01 unset joins none)"]
Inside a site, the space's own theme.chrome and whiteLabel are not drawn — the site's are. GET …/spaces/{spaceId}/theme then carries ignoredInSite (and a site.ignored warning per key you set), so you know at write time.
A site is needed to join one. The site is normally made for you at signup from the surfaces you pick in the welcome wizard, but an organization created through the API — or one whose only site was deleted — has none, and then nothing is joined. Check with GET /orgs/{orgId}/sites and create one if it is empty; see Sites. A site takes no spaces at creation, so making it first and passing its id is the cheap order.
Joining happens at creation only. Flipping a space from PRIVATE to PUBLIC later does not add it to the site — patch the site's spaceIds.
A PRIVATE space answers a logged-out reader with nothing at all — not a 403, nothing. Which is correct (a 403 confirms it exists) and is also the single most common reason a new space looks broken.
Light, dark or auto
The console's create-space screen (and the first-run setup) asks how the published space first looks: Auto (default — each reader's own setting), Light or Dark. Light and Dark write theme.colorScheme once the space exists; readers can still switch. Through the API it is the same theme key, after the space is made:
PATCH /orgs/{orgId}/spaces/{spaceId}/theme
{ "colorScheme": "dark" }On Auto or Light, a single logo (no logoUrlDark) — and a lone logoMark — is drawn inverted when a reader views the space dark, so a dark-ink mark stays readable on a dark header. A space pinned Dark is left alone: its logo is taken to be drawn for dark. logoDarkMode changes either.
The warnings in the response
{
"space": { … },
"warnings": ["The slug \"settings\" collides with a path the app answers on its own hostname."]
}Some slugs collide with routes the application serves on forumulate.com. On your own custom domain that is harmless — nothing of ours is mounted there — so it is a warning rather than a refusal. On the platform address, pick another slug.
Start from a template
A new blog or changelog can start as a finished site instead of an empty one. The official templates are ten complete spaces — nine blogs and a changelog — each exported from a live showcase site built entirely from theme settings (no custom CSS). Applying one gives the new space:
the look — palette, type, header and footer, the composed front page;
the layout — the index layout, the post layout, and the post page's own sidebar or after-post band where the template has one;
tag labels and colours, and two placeholder authors in the author registry;
a footer newsletter form, where the template has a sign-up row;
4–6 short sample posts (or releases), so the front page, the feeds and the cards have something to show on day one.
Template | Type | Modelled on |
|---|---|---|
Corvane | Blog | a payments company's product blog |
Kestrel | Blog | a developer cloud platform's engineering blog |
Margins | Blog | a design tool company's editorial magazine |
Kilter | Blog | a consumer technology news magazine |
Forgeline | Blog | a developer platform's engineering blog |
Halcyon | Blog | a consumer electronics newsroom |
Vectra | Blog | a productivity app's product blog |
Tinkerbloom | Blog | an independent front-end tutorial blog |
Kiln Journal | Blog | a design studio's journal |
Plinth | Changelog | a product-planning tool's changelog |
Each has a live preview — the site it was exported from — linked from its card.
In the console
New space → choose Blog (or Changelog) → the templates appear under Start from, each with a screenshot and a Live preview ↗ link. Pick one and create the space. You land in the studio with the template applied and its sample posts published.
Through the API
The template list is the same catalogue as every other space template. The official ones come first and are the entries with a previewUrl:
GET /orgs/{orgId}/space-templates?type=BLOG{
"templates": [
{
"id": "corvane",
"type": "BLOG",
"name": "Corvane",
"description": "A bright product blog with a gradient hero band, …",
"source": "builtin",
"published": true,
"pageCount": 5,
"previewUrl": "https://forumulate.com/b/showcase/corvane",
"thumbnailUrl": "https://forumulate.com/templates/corvane.jpg",
},
],
}Create the space first, then apply the template to it:
POST /orgs/{orgId}/spaces
{ "name": "Blog", "slug": "blog", "type": "BLOG", "visibility": "PUBLIC" }
POST /orgs/{orgId}/spaces/{spaceId}/apply-template
{ "templateId": "corvane" }The answer is the usual apply report: created is the number of sample posts written, skipped lists posts whose slug the space already had (applying twice is a merge, not a duplicate), and failed names any step that was refused — theme, layout, piece-page, tag:<slug>, author:<key>, or a post's slug — with the error. A template's type must match the space's: applying a blog template to a changelog is a 422.
What to replace
Every template is written against three placeholders, filled in when it is applied:
"Your Brand" — wherever the brand appears (header and footer copy, the wordmark picture) it becomes your organization's name. Change the copy in the studio like any other theme setting.
Pictures — the logo, wordmark, favicon, covers, avatars and decorative art are generic placeholders, copied into the space's asset library. Replacing one is an ordinary upload.
Links — header, footer and front-page links point at the new space's own address (
/b/{org}/{space}/…).
The sample posts and the two placeholder authors (Alex Morgan, Sam Rivera) are ordinary posts and registry entries: edit them into your own, or delete them.
Just the look
Each template's look is also a first-party listing in the theme marketplace, as template-<id> with a See it live ↗ link. Installing it there brings the look only — theme, front page and pictures, with your brand poured in — onto any space, without the layout, tags, authors or sample posts. The footer newsletter row is left out of a look-only install, because its form exists only when the whole template is applied.
The template files themselves live in the repository's templates/ folder, one directory per template, with a README describing each.
The slug rules
Shape | Lowercase letters, numbers and hyphens. |
Already taken in this org | 409 |
Reserved | 409 |
Changing it later | Possible, and it breaks every link to the space. Set up redirects first. |
The reserved list, in full
Two slugs are refused outright, on every domain including your own, because a space named either of them would publish, link, and then 404:
Slug | Why |
|---|---|
| The address of the REST API itself. Your own reader pages call it. Try |
| Reserved by the framework the reader is built on. |
That is the whole refusal list — everything else is allowed.
The warn-only list, also in full
These are segments this application answers to on its own hostname. A space may be named any of them; it simply will not be reachable at that name on forumulate.com, and the create call tells you so in warnings rather than refusing. On a custom domain of your own they work normally, because nothing of ours is mounted there:
account home new-org o welcome oauth platform
sso docs fonts accept-invite forgot-password
login register reset-password verify-email pricing
p b f r c st faq
jobs x legal newsletter sThe single-letter ones are the public reader prefixes (/p/ a page, /b/ a blog, /f/ a forum, and so on), which is why they look arbitrary and are not.
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.
Asset Library
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Update an asset’s alt text, caption, focal point, folder or tags |
| |
| List the media library’s folders |
| |
| Create a media library folder |
| |
| Rename or move a media library folder |
| |
| Delete a media library folder |
|
Custom Field
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the custom field |
| |
| Create the custom field |
| |
| Delete the field definition |
| |
| List values in an organization |
| |
| Create a value in an organization |
| |
| Set your own member fields |
|
Space
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Purge the space |
| |
| Restore the space |
| |
| Duplicate the space |
| |
| Move the space |
|
Space preview.svg
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the preview svg in a space |
|
Space spaces
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List spaces in an organization |
| |
| Create a space in an organization |
| |
| Get the overview in a space |
| |
| Get the space |
| |
| Update the space |
| |
| Delete the space |
|
Space Settings ai settings
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List AI settings in a space |
| |
| Replace AI settings in a space |
|
Space Settings reader access
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the reader access in a space |
| |
| Replace the reader access in a space |
|