Creating a space

Embed
Preview of the card
<a href="https://forumulate.com/guides/creating-a-space"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/fc5e9243-bdfe-4718-8e94-3a75e704c303/social-card.svg?v=3e7ae33eec8b" alt="Creating a space" width="600" height="315"></a>
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

true

A personal access token

false

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

seed: true effect

DOCS, FAQ, CAREERS, CONTACT, COURSES, CUSTOM, LEGAL

Creates the default "Pages" collection

FORUM, BLOG, ROADMAP, CHANGELOG

Nothing — their pieces are topics

EVENTS

Nothing — its pieces are events

STATUS

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 of GET /orgs/{orgId}/sites; any other id is a 422) and wears its header, footer and theme. Works for a PRIVATE space too: you asked.

  • siteId: null — joins no site. The space is published at its own address and wears its own theme.chrome.

  • unset — depends on your Forumulate-Version. From 2026-12-01 it joins no site, and warnings names the site you could have joined. Before that, and with no version, a PUBLIC space joins your organization's oldest site, as it always has, and warnings now 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 space.slug_taken

Reserved

409 space.slug_reserved

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

api

The address of the REST API itself. Your own reader pages call it. Try reference or api-docs.

_next

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          s

The 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

AssetLibrary_update

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

Update an asset’s alt text, caption, focal point, folder or tags

client.assets.library.update()

AssetLibrary_listFolders

GET /orgs/{orgId}/asset-folders

List the media library’s folders

client.assets.library.listFolders()

AssetLibrary_createFolder

POST /orgs/{orgId}/asset-folders

Create a media library folder

client.assets.library.createFolder()

AssetLibrary_updateFolder

PATCH /orgs/{orgId}/asset-folders/{folderId}

Rename or move a media library folder

client.assets.library.updateFolder()

AssetLibrary_deleteFolder

DELETE /orgs/{orgId}/asset-folders/{folderId}

Delete a media library folder

client.assets.library.deleteFolder()

Custom Field

Operation

Request

What it does

Shortcuts

CustomField_listDefs

GET /orgs/{orgId}/custom-fields/{entityType}

Get the custom field

client.orgs.customFields.listDefs()

CustomField_createDef

POST /orgs/{orgId}/custom-fields/{entityType}

Create the custom field

client.orgs.customFields.createDef()

CustomField_deleteDef

DELETE /orgs/{orgId}/custom-fields/defs/{id}

Delete the field definition

client.orgs.customFields.deleteDef()

CustomField_getValues

GET /orgs/{orgId}/custom-fields/{entityType}/{entityId}/values

List values in an organization

client.orgs.customFields.getValues()

CustomField_setValue

POST /orgs/{orgId}/custom-fields/{entityType}/{entityId}/values

Create a value in an organization

client.orgs.customFields.setValue()

CustomField_setOwnValues

PUT /orgs/{orgId}/custom-fields/member/me

Set your own member fields

client.orgs.customFields.setOwnValues()

Space

Operation

Request

What it does

Shortcuts

Space_purge

DELETE /orgs/{orgId}/spaces/{spaceId}/purge

Purge the space

forumulate spaces purge<br>client.spaces.purge()

Space_restore

POST /orgs/{orgId}/spaces/{spaceId}/restore

Restore the space

forumulate spaces restore<br>client.spaces.restore()

Space_duplicate

POST /orgs/{orgId}/spaces/{spaceId}/duplicate

Duplicate the space

forumulate spaces duplicate<br>client.spaces.duplicate()

Space_move

POST /orgs/{orgId}/spaces/{spaceId}/move

Move the space

forumulate spaces move<br>client.spaces.move()

Space preview.svg

Operation

Request

What it does

Shortcuts

Space_preview

GET /orgs/{orgId}/spaces/{spaceId}/preview.svg

Get the preview svg in a space

forumulate spaces preview<br>client.spaces.preview()

Space spaces

Operation

Request

What it does

Shortcuts

Space_list

GET /orgs/{orgId}/spaces

List spaces in an organization

forumulate spaces list<br>client.spaces.list()<br>MCP list_spaces

Space_create

POST /orgs/{orgId}/spaces

Create a space in an organization

forumulate spaces create<br>client.spaces.create()<br>MCP create_space

Space_overview

GET /orgs/{orgId}/spaces/overview

Get the overview in a space

forumulate spaces overview<br>client.spaces.overview()

Space_get

GET /orgs/{orgId}/spaces/{spaceId}

Get the space

forumulate spaces get<br>client.spaces.get()<br>MCP get_translation<br>MCP list_translations<br>MCP put_translation<br>MCP remove_translation<br>MCP set_name_translation<br>MCP auto_translate

Space_update

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

Update the space

forumulate spaces update<br>client.spaces.update()<br>MCP set_name_translation

Space_remove

DELETE /orgs/{orgId}/spaces/{spaceId}

Delete the space

forumulate spaces delete<br>client.spaces.remove()

Space Settings ai settings

Operation

Request

What it does

Shortcuts

SpaceSettings_getAiSettings

GET /orgs/{orgId}/spaces/{spaceId}/ai-settings

List AI settings in a space

client.spaces.settings.getAiSettings()

SpaceSettings_setAiSettings

PUT /orgs/{orgId}/spaces/{spaceId}/ai-settings

Replace AI settings in a space

client.spaces.settings.setAiSettings()

Space Settings reader access

Operation

Request

What it does

Shortcuts

SpaceSettings_getVisitorAuth

GET /orgs/{orgId}/spaces/{spaceId}/visitor-auth

Get the reader access in a space

client.spaces.settings.getVisitorAuth()

SpaceSettings_setVisitorAuth

PUT /orgs/{orgId}/spaces/{spaceId}/visitor-auth

Replace the reader access in a space

client.spaces.settings.setVisitorAuth()

The Forumulate teamUpdated