Developer quickstart
Copy, paste, run. Five calls to prove your token works and that you can read and write content, followed by the things that will trip you up.
Base URL: https://forumulate.com/api
Send Forumulate-Version: 2026-12-01 on every request. Without it you get the legacy behaviour: lists without the page envelope or the data key, unknown body fields silently dropped instead of refused, and a taken page slug quietly renamed instead of refused. See Versioning.
0. Get a token
Mint one at Account → Tokens (/account/tokens). Give it the read and write scopes.
export FORUMULATE_TOKEN=ak_…
export FML='https://forumulate.com/api'Minting a token over the API
POST /pats creates one, and it is the right call for issuing a second token from a first. It is not a way to bootstrap from nothing: it authenticates like every other call, so you need either a session cookie from a browser sign-in or an existing token with the admin scope. There is no username-and-password endpoint to trade credentials for a token — sign-in runs on Atlas and the API never sees a password.
So: get your first token in the browser, once. Then, if you need per-service tokens, mint them from it:
curl -sS -X POST "$FML/pats" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" \
-H 'Content-Type: application/json' \
-A 'my-app/1.0' \
-d '{
"name": "release-notes bot",
"scopes": ["read", "write"],
"capabilities": ["docs.read", "docs.write"],
"expiresInDays": 90
}'The response carries the secret once and never again, as token and as pat.secret.
Two gates, both of which a token needs: the coarse read/write/admin scope gates the HTTP method, and capabilities + constraints narrow what and where. A token with capabilities: ["docs.write"] but scopes: ["read"] cannot POST anything. API tokens, scopes and capabilities has the whole model.
1. Who am I
curl -sS "$FML/auth/me" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" \
-A 'my-app/1.0'Set a User-Agent. /api/auth/* is bot-protected and refuses some default library agents with a Cloudflare 403 rather than a JSON error. Any identifying string works. This is the single most common first-five-minutes failure.
2. Your organizations
curl -sS "$FML/orgs" -H "Authorization: Bearer $FORUMULATE_TOKEN" -A 'my-app/1.0'export ORG=<the id from that response>3. The spaces in one
curl -sS "$FML/orgs/$ORG/spaces" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -A 'my-app/1.0'A space can be addressed by its id or its slug in most paths, which is why you will see spaces/guides in examples. Both work.
4. What is in a space
curl -sS "$FML/orgs/$ORG/spaces/guides/items?limit=20" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -A 'my-app/1.0'/items is the uniform surface: a documentation page, a blog article, a roadmap card and a changelog entry are all items, and the space knows which it is. It also tells you where each piece's content lives, which saves you learning four content models.
Narrow the response with fields and include, both of which take a comma-separated list:
curl -sS "$FML/orgs/$ORG/spaces/guides/items?fields=id,title,slug,published&include=sections" …(The OpenAPI document currently declares these two as single-valued enums. The server accepts the list; a strict generated client may refuse to send one. See the note at the end.)
5. Read a page as markdown
curl -sS "$FML/public/orgs/$ORG/spaces/guides/pages/getting-started.md" \
-A 'my-app/1.0'No token needed — it is a public space. That endpoint is what to point a retrieval pipeline at.
Write something
# Ask the space what a piece of this kind wants
curl -sS "$FML/orgs/$ORG/spaces/guides/shape?preset=how-to" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -A 'my-app/1.0'
# Create it
curl -sS -X POST "$FML/orgs/$ORG/spaces/guides/items" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-A 'my-app/1.0' \
-d '{
"title": "Installing the CLI",
"preset": "how-to",
"sections": { "intro": "Two commands.", "steps": "1. npm i -g @forumulate/cli" }
}'The response names any required section still empty. Fill one with PUT /orgs/{orgId}/spaces/{spaceId}/items/{itemId}/sections/{key}, then
curl -sS -X POST "$FML/orgs/$ORG/spaces/guides/items/$ITEM/publish" \
-H "Authorization: Bearer $FORUMULATE_TOKEN" -A 'my-app/1.0'Idempotency-Key
POST …/items accepts an Idempotency-Key header. It is optional — a request without one behaves exactly as before — but send one anyway: a retried create without it makes a second page, and a retry is the normal outcome of a timeout you did not see.
Any string up to 200 characters. A UUID per logical create is the easy choice.
Or skip the REST entirely
Every SDK, the CLI, the MCP server and the website integrations are listed, with a quickstart each, in SDKs and integrations.
MCP
claude mcp add forumulate --transport http \
"https://forumulate.com/api/orgs/$ORG/mcp"Same token, same permissions, tools already defined. There is also a read-only public server per space at /api/public/orgs/{orgId}/spaces/{spaceId}/mcp, which needs no token at all.
Both servers — how to authenticate against each, what the public one refuses, and the mcp.json your client wants — are on one page: MCP servers. It is the only MCP page to link; three older ones redirect to it.
The SDK
npm only. There is one published SDK and it is the JavaScript/TypeScript one; forumulate and forumulate-sdk are not on PyPI and nothing is coming there. From Python, Go, Ruby or anything else, call the API directly — it is plain HTTP with a Bearer token, every operation is in the reference, and the OpenAPI document at https://forumulate.com/api/openapi.json will generate a client for your language if you want one.
npm install @forumulate/sdkimport { ForumulateClient } from '@forumulate/sdk';
const client = new ForumulateClient({
baseUrl: 'https://forumulate.com/api',
token: process.env.FORUMULATE_TOKEN,
});
const { orgs } = await client.request('GET', '/orgs');A public call needs no token:
import { ForumulateClient, typed } from '@forumulate/sdk';
const anon = new ForumulateClient({ baseUrl: 'https://forumulate.com/api' });
const resolved = await typed(anon, 'get', '/public/domains/resolve', {
query: { host: 'forumulate.com' },
});
// → { mounts, space, site }typed() gives you the path, the parameters and the response shape from the OpenAPI document, so a wrong path is a compile error.
One current limitation: the SDK cannot send arbitrary request headers, so it cannot send Idempotency-Key or Forumulate-Version. Use fetch for those two calls until that lands.
The CLI
export FORUMULATE_URL=https://forumulate.com/api
npx --yes @forumulate/cli whoami
npx --yes @forumulate/cli items "$ORG" guidesSee The command line, and forumulate doctor.
The machine-readable surface
URL | What it is |
|---|---|
| The OpenAPI 3 document. No authentication. |
| The site's surfaces, for an agent orienting itself. |
| Every published page, concatenated. |
| One space's page index. |
| One space, concatenated. |
| The space as an agent skill. |
| One page as markdown. |
| Where to report a vulnerability. |
On a custom domain, llms.txt, llms-full.txt and skill.md are served at the root of the address — https://docs.yourcompany.com/llms.txt. Under a path mount on a multi-space site they live at the site root, and each space's own copies are at the API URLs above.
Things that will trip you up
Set a User-Agent. Covered above. /api/* generally accepts anything; /api/auth/* does not.
The spec's own download URL. info.description currently says /openapi.json, which resolves against the host root and redirects. The real address is https://forumulate.com/api/openapi.json.
Rate limits. 600 requests a minute for an authenticated caller, 120 a minute for an anonymous one, keyed per principal. A handful of public endpoints are tighter — 60/minute or less — and say so in their RateLimit-Policy response header. Read Retry-After on a 429 rather than guessing.
Pagination is version-negotiated. State Forumulate-Version: 2026-09-01 (or later) and every collection-shaped GET answers { <rows>, data, page } with a cursor — data is the same rows under a key that is the same on every list, so one parser reads them all; send no version and you get the pre-envelope shape that route has always answered, which for some is a plain array. limit and cursor are accepted either way. The operation's response schema says which shapes it negotiates — see Pagination.
Deprecation headers. A route that negotiates its response shape carries Deprecation: @<unix seconds> (an RFC 9745 structured date, not true), a Sunset date, and a Link to the versioning page — and only when you state no Forumulate-Version. The route itself is not going away; the body you get without the header is. Which operations those are is in the spec, not in a list on this page: they declare the three headers on their 2xx and answer an anyOf whose withdrawn half is marked deprecated.
Errors have one shape.
{ "error": { "code": "auth.unauthenticated", "message": "Authentication required." } }code is stable and safe to branch on. message is for a human and may change. Some errors add details, docs and slugs.
Unknown body keys. A key we do not recognise is dropped, and the response names what it dropped in X-Unrecognized-Fields. Check that header the first time a field "does not save". The one surface that refuses outright rather than dropping is the theme, because a misspelled theme key is otherwise invisible.
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.
Mcp
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the MCP server in a space |
| |
| Create the MCP server in a space |
| |
| Get the server card in a space |
|