Skip to content
Fermium

REST API reference

The JSON endpoints under fermium.app/api that back the web app, with request and response examples.

Base URL: https://fermium.app. Everything under /api returns JSON.

New documents, uploads, databases and forms default to visibility: "restricted". A signed-in user still needs explicit access; workspace membership and listing: "team" never grant it. Unlisted/password links remain explicit external publication choices. When publishing or moving into a shared root, omit folder to use Members/<member name>, pass a project path for that destination, or pass "" to target the root itself.

Authentication

These endpoints are what the web app calls. They authenticate with the app session cookie set by sign-in, and state-changing requests are refused if an Origin header is present that is not https://fermium.app. In the examples below $COOKIE stands for that cookie as a name=value pair, copied from your browser's developer tools after signing in.

Scripts and desktop clients can send Authorization: Bearer <token> instead, with a static dsk_ token or an OAuth access token; it is honoured on every /api endpoint when the request carries no session cookie, and acts as the token's user. A few actions are reserved for a person signed in with the session cookie and refuse a token: saving a protected agent file directly, deleting one, and deciding an agent proposal (see Agents as agent memory). The MCP server exposes the same document operations as tools. The SCIM endpoints under /scim/v2 use their own bearer tokens and are documented on SCIM.

On Enterprise workspaces with an IP allowlist, every session-authenticated request from outside the list is a 403.

Errors are described in Errors. Unless noted, a successful response is the document object.

Me

GET /api/me

Who the session belongs to. 401 without a valid session.

curl -b "$COOKIE" https://fermium.app/api/me
{
  "email": "you@acme.com",
  "workspace": { "slug": "acme", "name": "Acme", "plan": "team" },
  "is_admin": false,
  "app_url": "https://fermium.app/app"
}

Documents

GET /api/docs

Your documents (up to 500, newest-updated first). Query parameters:

Parameter Effect
folder only documents directly in this folder. folder= (empty) is the root. Omit to list every folder.
recursive=true with folder, include its subfolders
tag only documents carrying this tag, in any folder. Combines with folder.
root root=<slug>: exactly that shared root, what you may open of it. root=~: exactly your My documents. view is ignored when root is given
view=mine the default: documents you own in My documents
view=team team-listed documents across your workspace, plus documents shared with you through an access grant, each with owner_email and mine. tag still applies; folder is ignored.
view=all the file browser's listing: your documents plus every team-listed or shared document you may open, plus the documents of every shared root you can see, merged and sorted by update time, each with owner_email and mine. folder, recursive and tag apply to every part.
curl -b "$COOKIE" "https://fermium.app/api/docs?folder=Customers%2FAcme"
curl -b "$COOKIE" "https://fermium.app/api/docs?folder=Customers&recursive=true"
curl -b "$COOKIE" "https://fermium.app/api/docs?tag=customer-facing"
curl -b "$COOKIE" "https://fermium.app/api/docs?view=all&folder=Customers%2FAcme"

Response: {"docs": [ ... ]}. Each document carries folder ("" at the top of its root), tags, alias and root ({id, slug, name, kind}, or null for a document in someone's My documents). With view=team or view=all, a document owned by someone else is included only if you may open it, by the same rule as opening its link: an explicit document, folder or shared-space grant gives you access. A document you may not open is left out entirely.

GET /api/tags

Your tags with live document counts.

{ "tags": [{ "tag": "acme", "count": 12 }, { "tag": "q3-review", "count": 3 }] }

include_team=1 adds the tags carried by team-listed and shared documents you may open. Counts cover only documents you may open, and a tag carried by a document you do not own is marked shared: true.

POST /api/docs

Publish an HTML document. JSON body, or multipart with a file field containing UTF-8 HTML and an optional title. Returns 201.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!doctype html><h1>Hello</h1>","title":"Hello","retention_days":0,"folder":"Customers/Acme","tags":["customer-facing"]}' \
  https://fermium.app/api/docs

Body fields: html (required), title, visibility, password, listing, retention_days, folder (path the document lives in; omitted or "" is the root; created on the fly), root (a shared root's slug, needs edit there; omitted or "~" is My documents), tags (list of up to 10). The multipart form takes only file and title; move or tag the document afterwards with PATCH. A workspace policy may restrict visibility and bound retention_days; the 400 says which values are allowed.

POST /api/snippets

Publish a code snippet. JSON body: content (required), language, title, visibility, password, listing, retention_days, root. Returns 201.

POST /api/files

Publish a single-file share. Multipart: file (required), optional filename, title, visibility, password, listing, retention_days, folder, root, render (false to share a .md as a download). Returns 201.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -F "file=@shot.png" -F "title=Login screen" \
  https://fermium.app/api/files

PUT /api/upload

Upload a local file as the raw request body. New .html/.htm uploads become rendered HTML documents, up to 20 MiB. JS/CSS file shares also have a 20 MiB cap. Other file shares, including PDF and media, allow up to 2 GiB. Metadata goes in the query string: filename (required), title, description (file shares only), visibility (restricted, unlisted or workspace), listing, retention_days, tags (comma-separated), idempotency_key. Accepts a bearer token or the session cookie. Returns 201 with the document, including files.

To replace an existing editable file, pass doc_id, path and base_version from the latest read, plus optional lock_token and idempotency_key. Omit new-document metadata. For HTML use path=index.html. Returns 200 with the same document URL and the new version; locks and version are rechecked after transfer (423 or 409 on conflict). The per-file limit is 20 MiB and the document total is enforced. Generated viewer pages and primary file-share payloads cannot be replaced. New HTML uploads also accept folder and root.

curl -T clip.mp4 -H "Authorization: Bearer $FERMIUM_TOKEN" \
  "https://fermium.app/api/upload?filename=clip.mp4&title=Demo&tags=acme,demos&retention_days=0"

A Content-Length above the limit fails fast with 413 before any bytes are read.

GET /api/docs/{doc_id}

One document with files: [{path, size, content_type}] and its icon. 404 if it is not yours and you hold no edit access to it.

PATCH /api/docs/{doc_id}

Change settings and, optionally, content. All fields optional:

Field Effect
html replace index.html (HTML documents only); title is applied at the same time
title rename
visibility, password change access
listing private or team
retention_days integer >= 0
tags full replacement; [] clears
folder move the document to this folder; "" moves it to the root. The URL does not change and no version is created
root move between roots: a shared root's slug hands the document to that root (the owner, with edit on the target folder); "~" or null takes it back into My documents (the author, or a root manager, who then owns it). Omit to stay in the current root. Combine with folder; without it the folder path is kept

For a document in My documents, html, title and tags are open to editors; the rest is owner-only and answers 403 for an editor. A document in General or a shared root belongs to the root: everyone with edit there may change every field.

curl -X PATCH -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" \
  -d '{"visibility":"password","password":"harbor-42","retention_days":0,"tags":["customer-facing"]}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2

curl -X PATCH -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"folder":"Archive/Acme"}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2

DELETE /api/docs/{doc_id}

Soft-delete. Response: {"doc_id": "...", "deleted": true}. 423 if the document is under legal hold.

POST /api/docs/{doc_id}/files

Add supporting files. Multipart: one file with an optional path field, or several files parts whose file names become paths. Returns 201 with the document plus added and files (paths).

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -F "file=@chart.png" -F "path=img/chart.png" \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/files

DELETE /api/docs/{doc_id}/files?path=...

Remove one file. Returns the document with files.

Background: Links between documents. All owner-only except GET /api/resolve.

PUT /api/docs/{doc_id}/alias

Body: {"alias": "q3-roadmap", "force": false}. Sets or moves the document's alias. 409 when another live document in the workspace holds it and force is false; the message names the holder.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"alias":"q3-roadmap"}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/alias
{ "doc_id": "01J7ZK3N9QW0X4B2C6D8E0F1G2", "alias": "q3-roadmap", "url": "https://fermiumusercontent.app/d/xBVNwKdi7ukQhXrWhQuz4g/", "link_url": "/x/q3-roadmap" }

DELETE /api/docs/{doc_id}/alias

Clears the alias. Same payload with alias and link_url null.

Outbound [[refs]] and backlinks.

{
  "doc_id": "01J7…", "alias": "postmortem-2026-09",
  "outbound": [{ "ref": "q3-roadmap", "path": null, "href": "/x/q3-roadmap", "resolved": true, "doc_id": "01J8…", "title": "Q3 roadmap", "alias": "q3-roadmap", "url": "https://fermiumusercontent.app/d/…/" }],
  "inbound": [{ "doc_id": "01J9…", "title": "Weekly notes", "alias": null, "url": "https://fermiumusercontent.app/d/…/", "ref": "postmortem-2026-09", "path": null }]
}

GET /api/resolve?ref=...

Resolve an alias, doc:<id>, bare id or @workspace/alias inside your workspace.

{ "ref": "q3-roadmap", "found": true, "doc_id": "01J8…", "title": "Q3 roadmap", "alias": "q3-roadmap", "url": "https://fermiumusercontent.app/d/…/", "visibility": "workspace" }

Not found: {"ref": "...", "found": false}.

GET /x/{ref} and /x/{ref}/{path} (content host)

On fermiumusercontent.app and on custom domains. 302 to /d/<slug>/<path> on the same host with Cache-Control: private, no-store; the neutral not-found page when the reference does not resolve. A bare alias resolves on a custom domain within that workspace, and on the shared host only when exactly one live document anywhere holds it.

Search, text and edits

Background and limits: Search and edit.

GET /api/search

Grep your documents, the shared roots you can open, and team-listed documents. Query parameters: q (required), regex, case_sensitive, folder, recursive (default true), root (~ for My documents only, or a shared root's slug), tag, kinds (comma-separated), path_glob, include_team (default true), limit (default 50, max 200), context (default 1, max 10).

curl -b "$COOKIE" "https://fermium.app/api/search?q=pgbouncer&path_glob=*.md&context=2"
{
  "query": "pgbouncer", "count": 1,
  "results": [{ "doc_id": "01J7…", "title": "Payments on-call", "url": "https://…/d/…/", "folder": "Runbooks", "kind": "markdown", "mine": true, "owner_email": "ada@acme.com", "path": "source.md",
    "matches": [{ "line": 42, "text": "Restart pgbouncer before failing over.", "before": ["", "## Failover"], "after": ["", "Then check replication lag."] }] }]
}

GET /api/docs/{doc_id}/grep

pattern (required), path, regex, case_sensitive, context (default 2). Returns {doc_id, title, url, version, files: [{path, matches}], total_matches, truncated}.

GET /api/docs/{doc_id}/text

path (required), start, end (1-based, inclusive). Returns {doc_id, path, content, total_lines, start_line, end_line, version, size}. Text files up to 1 MiB; 400 for binary, oversize or non-UTF-8 files.

curl -b "$COOKIE" "https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/text?path=source.md&start=40&end=60"

POST /api/docs/{doc_id}/edit

Body: {"path": "source.md", "old_string": "...", "new_string": "...", "replace_all": false}. old_string must match exactly once unless replace_all. Returns the document object plus path, replacements, changed, version. 400 with the match count when it matches zero or several times.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"path":"source.md","old_string":"+1 555 0199","new_string":"+1 555 0100"}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/edit

POST /api/docs/{doc_id}/patch

Body: {"diff": "--- a/source.md\n+++ b/source.md\n@@ -1,3 +1,3 @@\n..."}, a unified diff as git diff prints it. Nothing is written unless every hunk applies. Returns the document object plus files: [{path, action, changed}] and version.

GET /api/docs/{doc_id}/versions

{doc_id, versions: [{version, created_at, created_via, total_bytes, current, files: [{path, size, sha256}]}]}, newest first.

POST /api/docs/{doc_id}/versions/{version}/restore

Writes a new version equal to version. Returns the document object with files, restored_from and changed. 404 for an unknown version.

Edits, patches and restores are recorded in the audit log as doc.edit, doc.patch and doc.restore.

GET /api/search/ranked

Ranked full-text search over everything you can see (your documents plus team-listed ones), one result per document, best file first, with a highlighted excerpt. Query parameters: q (required, at most 200 characters), folder, recursive (default true), tag, kinds (comma-separated), include_team (default true), limit (default 20, at most 100). Words are matched with implicit AND, the title weighs more than the body, and the last word matches as a prefix so search-as-you-type works.

curl -b "$COOKIE" "https://fermium.app/api/search/ranked?q=pgbouncer+failover&folder=Runbooks"
{
  "query": "pgbouncer failover", "count": 1,
  "results": [{ "doc_id": "01J7…", "title": "Payments on-call", "url": "https://…/d/…/", "folder": "Runbooks", "kind": "markdown", "tags": ["runbook"], "icon": null, "alias": "payments-oncall", "visibility": "workspace", "mine": true, "owner_email": "ada@acme.com", "updated_at": "2026-09-16T09:12:40",
    "path": "source.md", "snippet": "Restart pgbouncer before failing over.", "snippet_html": "Restart <mark>pgbouncer</mark> before <mark>failing</mark> over.", "score": -4.21 }]
}

score is lower for better matches. The response also names the search engine in use. The wiki's search box uses the same index scoped to the wiki.

Editing

The endpoints behind the browser editor and sync clients: raw file reads with caching headers, whole-file and find-and-replace writes with a version check and lease awareness, edit leases, a markdown preview, and a change feed for sync clients. Reads need the same access as viewing the document; writes need the owner or edit access. Every write counts against the publish quota.

GET /api/docs/{doc_id}/manifest

?version=n for an older version. {doc_id, version, current_version, updated_at, manifest: {path: {sha256, size, content_type}}}. 404 for an unknown version.

GET /api/docs/{doc_id}/files/{path}

The raw bytes of one file in the current version, or ?version=n for an older one. Served as an attachment with X-Content-Type-Options: nosniff, a restrictive Content-Security-Policy and Cache-Control: private, no-cache, because this is the app origin, not the content host. ETag is the file's SHA-256 in quotes; send it back as If-None-Match and an unchanged file answers 304 with no body. X-Doc-Version names the version served.

curl -b "$COOKIE" -o source.md -D - \
  -H 'If-None-Match: "3f9a1c2b…"' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/files/source.md

PUT /api/docs/{doc_id}/files/{path}

Replace one file. The path picks the updater: source.md re-renders the markdown page, a snippet's source re-highlights it, index.html of an HTML document updates in place, anything else is stored as a supporting file. The generated index.html of a markdown, snippet or file document is refused with a message naming the source to edit.

Body: {"content": "...", "base_version": 6, "encoding": "utf8", "lock_token": "..."}. base_version, encoding (utf8 or base64) and lock_token are optional. Returns the document object plus path, changed, version, sha256, size. Recorded as doc.edit.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"content":"# Payments on-call\n...","base_version":6}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/files/source.md

Two refusals carry their own JSON body:

409, stale base_version. The document is no longer at the version the client read.

{
  "error": "doc is at version 7, not 6; re-read the file and retry with the current version",
  "current_version": 7,
  "base_version": 6,
  "changed_by": "ada@acme.com",
  "diff": "--- a/source.md@v6\n+++ b/source.md@v7\n@@ -12,7 +12,7 @@\n ..."
}

diff is a unified diff of that file from the client's version to the current one; "" when the file itself is unchanged between the two (another file moved); null when either side is not text. changed_by is the actor of the newest write, or null.

423, leased by someone else. Another user holds the document's edit lease and the request did not carry its token.

{ "error": "locked by ada@acme.com until 2026-09-16T09:22:40+00:00", "holder": { "email": "ada@acme.com" }, "expires_at": "2026-09-16T09:22:40+00:00", "path": "source.md" }

Checks run in this order: edit rights (404 for a stranger), lease (423), version (409), then the write.

PATCH /api/docs/{doc_id}/files/{path}

Exact find-and-replace with the same guards. Body: {"old_string": "...", "new_string": "...", "replace_all": false, "base_version": 6, "lock_token": "..."}. old_string must match exactly once unless replace_all. Returns the document object plus path, replacements, changed, version; 400 with the match count, 409 and 423 as above.

Edit leases

A lease is per document, held by one user, for at most 600 seconds, renewable. It blocks other users' writes through every channel (the editor, these endpoints, agents) unless they present its token; it never blocks reads, and never blocks the holder's other clients. Expired leases are ignored and cleared on the next touch.

Method and path Body Effect
GET /api/docs/{doc_id}/lock {doc_id, lock: null} or {doc_id, lock: {doc_id, holder: {email, user_id}, expires_at, acquired_at, path, mine}}. Anyone who can view the document.
POST /api/docs/{doc_id}/lock {"path": "source.md", "ttl_seconds": 600} (both optional) 201 with the lock and, only here, its token. Re-taking your own lease issues a fresh token and expiry. 423 with the holder when someone else holds it.
POST /api/docs/{doc_id}/lock/renew {"token": "...", "ttl_seconds": 600} extends your lease; 404 for a token that does not match, 423 if someone else took it in between.
DELETE /api/docs/{doc_id}/lock {"token": "..."} or ?token=; force=true releases. An expired lease can be cleared by anyone with edit rights. The document's owner may pass force=true to break another user's lease; the audit log records lock.release with forced: true.
curl -X POST -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"path":"source.md","ttl_seconds":300}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/lock
{ "doc_id": "01J7…", "holder": { "email": "you@acme.com", "user_id": 3 }, "expires_at": "2026-09-16T09:27:40+00:00", "acquired_at": "2026-09-16T09:22:40+00:00", "path": "source.md", "mine": true, "token": "…" }

Leases are recorded as lock.acquire and lock.release in the audit log.

POST /api/render/markdown

Body: {"content": "# Draft…", "doc_id": "01J7…"} (doc_id optional; it must be readable when given). Returns {"html": "<h1>…", "has_mermaid": false}: the body HTML the published page would contain, rendered by the same pipeline as publishing with [[wiki links]] resolved in your workspace, without page chrome. Content is capped at 2 MiB (413). Rate limited per user; the editor sends one request after each pause in typing.

GET /api/changes

The change feed for sync clients; the full recipe is in Building a sync client. Query parameters: since (an opaque cursor from a previous response; omit for the beginning), folder, recursive (default true), include_team (default false), root, limit (default and maximum 200).

root picks the scope. Omitted: your My documents, plus, with include_team=true, team-listed documents and every shared root you can see. root=~: My documents only (plus team-listed documents with include_team=true), never the shared roots. root=<slug>: exactly that shared root, as far as you may open it.

Returns {changes: [...], next_cursor, has_more} with documents ordered oldest change first. Each change is {doc_id, title, folder, root, tags, kind, version, updated_at, changed_at, deleted, deleted_at, owner_email, listing, manifest} where manifest maps each path to {sha256, size, content_type} and is empty for a deleted document. changed_at is the latest of the content update, the deletion, and the newest metadata change (rename, move, tags, visibility, listing, description, alias, legal hold). Deleted documents stay in the feed with deleted: true for 30 days.

curl -b "$COOKIE" "https://fermium.app/api/changes?folder=Runbooks&include_team=true"
curl -b "$COOKIE" "https://fermium.app/api/changes?since=MjAyNi0wOS0xNlQwOToxMjo0MHwwMUo3…"

The feed carries only documents the caller may open. A colleague's team-listed document that a grant has narrowed away from the caller is skipped entirely, with no title or manifest, and the cursor still advances past it; a document shared with the caller through a grant is included. See Building a sync client.

Tree and bundles

Background: Agents for the access rule. Both endpoints include only documents the caller may open and report the rest as a count in skipped.

GET /api/tree

Query parameters: folder (default the root), depth (default 1; 0 for everything, at most 8), include_team (default false), root (a shared root's slug for that root's tree; default My documents). Returns {folder, depth, include_team, folders: [...], docs: [...], doc_count, skipped}. A folder node is {path, name, icon, description, doc_count, docs, children}, or {…, truncated: true} at the depth boundary; a document row is {doc_id, title, kind, icon, alias, size, updated_at, url, tags, visibility} plus own and owner with include_team. The shape is the one list_tree returns in the MCP reference.

curl -b "$COOKIE" "https://fermium.app/api/tree?folder=Runbooks&depth=2"

POST /api/bundles

Body: {"folder": "Runbooks", "recursive": true, "include_team": false, "root": "general"} (all optional; without root the bundle is taken from My documents). Zips the current version of every document under the folder that the caller may open and returns 201 with a signed download link:

{ "url": "https://fermium.app/api/bundles/<token>", "expires_at": "2026-09-16T09:22:40+00:00", "bytes": 1048576, "doc_count": 12, "file_count": 31, "skipped": 1, "folder": "Runbooks", "recursive": true, "filename": "Runbooks.zip", "curl": "curl -sS -L -o Runbooks.zip \"https://fermium.app/api/bundles/<token>\"" }

Layout inside the zip: <folder>/<title>/<files> per document with a meta.json (doc_id, title, kind, alias, folder, tags, version, visibility, url, updated_at, files, attrs), and index.json at the root with folder, recursive, generated_at, docs: [{path, doc_id, title, version}] and skipped: {docs}. Limits: 500 MiB uncompressed per bundle (413, checked before any file is read), 6 bundles per hour per account (429). 400 when nothing under the folder can be bundled. Recorded in the audit log as doc.bundle.

GET /api/bundles/{token}

The download. The signed token is the credential and is good for 10 minutes; a signed-in caller must be the user who created the bundle (403 otherwise). One download only: the zip is streamed once and then deleted, so a second request answers 410. 404 for an invalid or expired token. Bundles that are never fetched are removed within the hour.

File history, diff and blame

Background: Versions and history. Reads need the same access as viewing the document. Payload shapes are those of file_history, file_diff and file_blame in the MCP reference.

GET /api/docs/{doc_id}/history?path=source.md

The versions that added, changed or removed path, newest first, each with version, created_at, actor, created_by_user_id, via, message, change, size, sha256 and, for text files, added and removed line counts. 404 when no version holds the path.

GET /api/docs/{doc_id}/diff?path=source.md&from=4&to=7

to defaults to the current version. {doc_id, path, from_version, to_version, from, to, identical, text, unified, hunks, added, removed}; unified is a unified diff, hunks the same change as side-by-side rows. Binary files return text: false with sizes and hashes only.

GET /api/docs/{doc_id}/blame?path=source.md&version=7

version defaults to the current one. {doc_id, path, version, current_version, lines: [{line_no, text, version, actor, created_at, message}], versions: [...]}. Text files up to 1 MiB; 400 when the file is not text in some version.

curl -b "$COOKIE" "https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/blame?path=source.md"

Rendered document

GET /api/docs/{doc_id}/rendered?agent=support&path=notes.md

The payload behind the app's Read view. Returns {doc_id, title, kind, alias, folder, tags, url, path, html, toc, mermaid, backlinks, references, version, updated_at, actor, brain, in_brain, attrs}. Markdown is rendered by the same pipeline as the wiki with [[links]] resolved and heading anchors added; toc lists {id, text, level}. For an HTML document or a file share html is null and the client frames the document's own address. path renders another text file of the document (markdown files through the markdown pipeline, other text highlighted). backlinks lists documents that link here; references lists this document's outbound links that name a file, each with {doc_id, title, alias, ref, path, line_from, line_to, href, url}, so a citation such as [[runbook/source.md#L12-L20]] carries its lines. With brain, links whose target is a member of that agent point into the Read view (/app/agents/<slug>/<ref>) and in_brain says whether this document is one; everything else keeps the /x/ form. Backlinks and references include only documents the caller may open.

Icons

Background and grammar: Icons.

GET /api/icons

{lucide: [names], colors: [names], library: [{id, icon, name, url, bytes, sha256, uploaded_by, created_at}], library_available, grammar}.

POST /api/icons

Team and above. Body: {"name": "Acme mark", "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" …>"}. Validates the SVG (at most 32 KiB; scripts, event handlers, embedding elements, DOCTYPE and entities, and any reference that leaves the file are rejected with the reason) and returns 201 with the library entry. Identical bytes return the existing entry.

DELETE /api/icons/{id}

The uploader or a workspace owner or admin. Clears every reference to the icon so nothing renders a dead svg:<id>. {"id": 3, "deleted": true}.

PUT /api/icons/target

Body: {"target": "folder:Runbooks", "icon": "lucide:wrench/amber"}. target is doc:<id>, folder:<path>, tag:<slug>, nav:<entry_id> or navigation:<slug>. Returns {target, icon} in stored form. Who may set what is in Icons.

DELETE /api/icons/target?target=...

Clears the icon. {target, icon: null}.

PUT /api/icons/description

Body: {"target": "folder:Runbooks", "description": "Playbooks for on-call"}; target is a folder or a tag; null or "" clears. Up to 512 characters. Returns {target, description}.

GET /icons/{workspace-slug}/{id}.svg (content host)

The library SVG as image/svg+xml with the sandbox Content-Security-Policy and Cache-Control: public, max-age=86400. On a custom domain only that workspace's icons resolve; anything else is the neutral not-found page.

Roots and folder features

Background: Roots and folders. {root} in a path and root= in a query take a shared root's slug or numeric id; ~, or nothing, means the caller's own My documents. Listing a root's documents and folders uses the existing endpoints with root=: GET /api/docs, GET /api/folders, GET /api/tree, GET /api/changes. A root the caller cannot see is a 404; a level that is too low is a 403 whose message names the level the caller holds.

The root object:

{ "id": 4, "slug": "engineering", "name": "Engineering", "kind": "shared",
  "description": "Designs, runbooks and incident reviews.", "icon": "lucide:wrench/blue",
  "is_general": false, "my_level": "edit", "doc_count": 17, "can_manage": true, "published": false,
  "created_at": "2026-09-17T08:00:00+00:00", "updated_at": "2026-09-17T08:00:00+00:00" }

my_level is view, comment, edit or null at the top of the root. doc_count counts documents the caller may open. published is whether the top of the root is published as a site.

GET /api/roots

{ "personal": { "slug": "~", "name": "My documents", "doc_count": 31 },
  "roots": [ { "slug": "general", "is_general": true, "…": "…" }, { "slug": "engineering", "…": "…" } ],
  "can_create": true }

General is first and always present; other shared roots appear when the caller holds any level inside them or manages the workspace.

POST /api/roots

Workspace owners and admins. Body: {"name": "Engineering", "slug": "engineering", "description": "…", "icon": "lucide:wrench/blue"}; only name is required. Returns 201 with the root object. 402 below the Team plan, 403 for other members, 409 when the slug exists, 400 for a reserved slug or past 200 shared roots. The access list starts empty and the space is restricted to its managers.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"name":"Engineering"}' \
  https://fermium.app/api/roots

GET /api/roots/{root}

The root object. 404 for ~.

PATCH /api/roots/{root}

Root managers: workspace owners and admins, and the root's creator. Body: any of name, slug, description, icon; send null to clear description or icon. General keeps its slug. Returns the root object.

DELETE /api/roots/{root}?force=

Root managers. A root that still holds documents answers 409 unless force=true, which moves its documents into General with their folders intact. Its access lists, folder settings, shortcuts and stars are removed. General cannot be deleted. Response: {"root": "engineering", "deleted": true, "moved_docs": 17}.

GET /api/roots/{root}/access?folder=

The access list at the top of the root, or at folder. Anyone who can view that place may read it.

{
  "root": { "id": 4, "slug": "engineering", "name": "Engineering", "kind": "shared" },
  "folder": "Security reviews",
  "my_level": "edit",
  "can_manage": true,
  "access_blocked": null,
  "grants": [
    { "id": 91, "subject_type": "group", "subject_id": 3, "subject": { "id": 3, "slug": "security", "name": "Security" },
      "subject_label": "group:security", "level": "edit",
      "created_by": "ada@acme.com", "created_at": "…", "root_id": 4, "folder": "Security reviews" }
  ],
  "inherited_from": null,
  "inherited": [],
  "overrides": true
}

grants are the entries set exactly here. With overrides: false, inherited_from names the nearest parent audience and inherited contains its entries. With overrides: true, the folder replaces its parent audience even when its own list is empty. General has an explicit workspace/edit grant. New shared roots start manager-only. access_blocked is null; group writes enforce their plan requirement individually.

POST /api/roots/{root}/access/inherit

Root managers. Body: {"folder":"Engineering/Reviews"}. Clears this folder's own grants and restriction marker, deliberately restoring parent permissions. Returns the access payload. Removing the final grant through DELETE does not restore inheritance.

POST /api/roots/{root}/access

Root managers. Individual and workspace subjects work on every plan; group subjects require Team or above (402 otherwise). Adds an entry, or changes the level of an existing one.

Field Notes
subject_type user, group or workspace
subject the email for user, the group slug for group; omit for workspace
level view (default), comment or edit
folder "" (default) for the root's own list, or a folder path for that folder's list

Returns 201 with the access payload above for that place. The top-level list of General cannot be changed (400); set a list on a folder inside it instead. 404 for a person or group that is not in the workspace.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" \
  -d '{"subject_type":"workspace","level":"view","folder":"Handbook"}' \
  https://fermium.app/api/roots/engineering/access

DELETE /api/roots/{root}/access/{grant_id}

Root managers, Business and above (402 otherwise). Response: {"id": 91, "deleted": true}. DELETE /api/access/{grant_id} accepts the same ids.

GET /api/folder-meta?root=&path=

Folder settings. path= (empty) is the top of the root.

{ "icon": "lucide:book-open", "description": "How we work.", "index_doc_id": "01J7…",
  "view_mode": "page", "child_order": ["d:01J7…", "f:Tools", "s:12"],
  "published": true, "publish_visibility": "signed_in", "publish_group_id": null,
  "root": "general", "path": "Handbook", "my_level": "edit", "can_publish": false,
  "publishing_policy": "public", "publish_blocked": null }

can_publish says whether the caller may change the publish keys here. publishing_policy is the workspace rule for folder sites (public, signed_in or off; see Workspace policy). publish_blocked says why publishing is unavailable to everyone: "plan" when the workspace is below Team, "policy" when the rule is off, otherwise null. Unpublishing works in every case.

PUT /api/folder-meta?root=&path=

Patch folder settings: only keys present in the body change, and null clears one. Needs edit on the folder. The keys published, publish_visibility and publish_group_id need a root manager in a shared root (403 otherwise); in My documents they are the caller's own.

Key Notes
icon icon grammar
description up to 512 characters
index_doc_id a document of the same root that the caller may open; in My documents, one of the caller's own
view_mode grid, list or page
child_order list of keys f:<folder name>, d:<doc_id>, s:<shortcut_id>, at most 2,000
published boolean
publish_visibility public (default), signed_in or group
publish_group_id a group of this workspace; required when a published folder uses group

A request that touches the publish keys and leaves the folder published needs the Team plan or above (402 otherwise) and has to fit the workspace publishing rule: 403 when the rule is off, and 403 for public when the rule is signed_in. {"published": false} is always accepted.

Returns the settings as GET does. An unknown key is a 400. Publishing and unpublishing are recorded in the audit log as folder.publish and folder.unpublish. The site is served on the content host at /w/@<workspace>/r/<root-slug>/<path>/, and at /w/r/<root-slug>/<path>/ on a custom domain; My documents use ~<user id> as the root segment.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" \
  -d '{"index_doc_id":"01J7ZK3N9QW0X4B2C6D8E0F1G2","view_mode":"page"}' \
  "https://fermium.app/api/folder-meta?root=general&path=Handbook"

GET /api/folder-view?root=&path=

Everything the navigator's main pane shows for one folder, in one call: {root, path, crumbs, meta, publishing_policy, publish_blocked, index, folders, docs, shortcuts, order, my_level, can_manage}. publishing_policy and publish_blocked are the ones from GET /api/folder-meta. The shape, with an example, is under get_folder_view in the MCP reference.

POST /api/shortcuts

Body: {"root": "general", "folder": "Sales", "target_doc_id": "01J9…", "label": "Pricing FAQ"}; only target_doc_id is required, and root omitted means My documents. Needs edit on the folder; the target must be a document of the same workspace that the caller may open (404 otherwise). Returns 201 with {id, label, folder, root, target}, where target is the document object.

DELETE /api/shortcuts/{id}

Needs edit on the shortcut's folder, or, in My documents, to be its owner. Response: {"id": 12, "deleted": true}. The target document is untouched.

Agents as agent memory

Background: Agents. These endpoints live under /api/brain-roots; the endpoints under /api/brains keep serving agents of the earlier form. {slug} is an agent's slug or id; personal is the caller's own agent. An agent the caller cannot read is a 404. The agent, proposal and journal event objects are the ones in the MCP reference.

The Agent runtime reference adds hosted runs, private/shared sessions, human-reviewed knowledge, public knowledge deployments and AI credits. Credit changes, deployment management and memory submission/approval require the signed-in browser and the documented role in addition to API authentication.

Two things depend on how the request is authenticated. A write to a protected file (BRAIN.md, skills/, workflows/) is applied at once only for a person signed in with the session cookie who has write access and sends direct: true; a request authenticated with a bearer token always gets a proposal. And only a session-cookie request can decide a proposal. Reads made with the session cookie are not recorded in the agent's journal; reads made with a token are.

GET /api/agent-roots

{brains: [brain], personal, can_create, limit, count}. personal is the slug of the caller's personal agent, which is created on first use. limit is the number of workspace agents the plan allows (null for no limit) and count how many exist; personal agents are not counted.

POST /api/agent-roots

Workspace owners and admins. Body: {"name": "Support", "slug": "support", "description": "…", "icon": "lucide:life-buoy"}; only name is required. Returns 201 with the agent object; the agent starts with a starter BRAIN.md and INDEX.md. 409 when the slug exists. 402 when the workspace already has as many workspace agents as its plan allows: 1 on Free, 5 on Team, no limit on Business and above.

GET /api/agent-roots/{slug}

The agent object plus files (the typed tree), sources, includes and consolidation.

PATCH /api/agent-roots/{slug}

Owners and admins; the owner, for a personal agent. Body: any of name, slug, description, icon, clear_icon (boolean) and journal_budget (1,000 to 2,000,000 characters). A personal agent keeps its slug.

DELETE /api/agent-roots/{slug}

Owners and admins. Deletes the agent with its files, journal, proposals, sources, includes and access list. A personal agent cannot be deleted (400). Response: {"deleted": true}.

GET /api/agent-roots/{slug}/files

{
  "brain": "support",
  "tree": [
    { "kind": "file", "path": "BRAIN.md", "name": "BRAIN.md", "type": "instructions", "protected": true, "doc_id": "01JA…",
      "title": "Support", "version": 4, "bytes": 1820, "updated_at": "…", "updated_by": "ada@acme.com", "pending_proposals": 1 },
    { "kind": "file", "path": "INDEX.md", "…": "…" },
    { "kind": "folder", "path": "skills", "name": "skills", "type": "skill", "protected": true, "children": [ { "kind": "file", "path": "skills/reply-style.md", "…": "…" } ] },
    { "kind": "folder", "path": "workflows", "type": "workflow", "protected": true, "children": [] },
    { "kind": "folder", "path": "memory", "type": "memory", "protected": false, "children": [ { "kind": "folder", "path": "memory/customers", "children": [ "…" ] } ] }
  ],
  "files": [ { "path": "BRAIN.md", "…": "…" } ],
  "count": 12,
  "can_write": true,
  "pending_proposals": 1
}

The three folders are always present, possibly empty. files is the same set as a flat list.

GET /api/agent-roots/{slug}/file?path=

One file: the fields of a file row plus markdown, can_write, write_mode (direct or proposal) and, unless render=false, html and has_mermaid from the same renderer as the editor preview.

PUT /api/agent-roots/{slug}/file

Field Notes
path BRAIN.md, INDEX.md, skills/<name>.md, workflows/<name>.md, memory/<topic>.md (memory may nest four folders deep)
content the complete markdown; must not be empty
message one line for history, the journal and the proposal
base_version the version the edit started from; a mismatch is a 409 with the same conflict body as PUT /api/docs/{doc_id}/files/{path}
direct true to save a protected file at once; honoured only for a session-cookie request from someone with write access

A direct write returns {brain, mode: "direct", written: true, changed, path, type, version, …}. A protected file without direct, from a reader, or from a token returns {brain, path, type, mode: "proposal", proposal, note} and the file is unchanged. direct: true from a signed-in person without write access is a 403.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" \
  -d '{"path":"memory/customers/acme.md","content":"# Acme\n\nRenews in March.\n","message":"Renewal date","base_version":3}' \
  https://fermium.app/api/brain-roots/support/file

DELETE /api/agent-roots/{slug}/file?path=

Write access. BRAIN.md and INDEX.md cannot be deleted (400); a skill or workflow can be deleted only by a session-cookie request (403 for a token). Response: {"deleted": true}.

Sources

  • GET /api/brain-roots/{slug}/sources returns {brain, sources: [{id, kind, target, label, note, exists}], scope_count}. scope_count is the number of source documents the caller may open. A document source shows its title only to callers who may open it.
  • POST /api/brain-roots/{slug}/sources (write access) takes {"kind": "folder", "target": "general:Support", "note": "Policies and macros."} and returns 201 with the source. kind is root (a shared root's slug, or ~), folder (<root slug or ~>:<path>), tag or doc (an id or alias). Adding an existing source returns it.
  • DELETE /api/brain-roots/{slug}/sources/{id}, or DELETE /api/brain-roots/{slug}/sources?id=, removes one: {"deleted": true}.

Includes

  • GET /api/brain-roots/{slug}/includes returns {brain, slugs, includes: [{slug, name, description, readable, position}]}. An included agent the caller cannot read has readable: false and no slug or name.
  • PUT /api/brain-roots/{slug}/includes (write access) replaces the ordered list: {"brains": ["brand", "legal"]}. 400 for the agent itself, a personal agent, a cycle, or a chain deeper than three.

Access

  • GET /api/brain-roots/{slug}/access returns {brain, personal, owner, grants: [{id, subject_type, subject, subject_ref, subject_label, level, inherited}], narrowed, my_level, can_manage, access_blocked, note}. access_blocked is "plan" when the workspace is below Business and the list cannot be changed. Levels are view (read and propose) and edit (write and decide).
  • POST /api/brain-roots/{slug}/access (owners and admins, Business and above, 402 otherwise) takes {"subject": "group:support", "level": "edit"}, where subject is user:<email>, group:<slug> or workspace and level is view, edit, or none to remove. {"subject_type": "user", "subject": "ada@acme.com", "level": "view"} is accepted too. Returns the access payload. A personal agent cannot be shared (400).
  • DELETE /api/brain-roots/{slug}/access/{grant_id} (Business and above, 402 otherwise) removes one entry and returns the access payload.

GET /api/agent-roots/{slug}/log?since=&kinds=&limit=&unconsolidated=

The journal, newest first. kinds is a comma-separated list from note, load, read, write, search, propose, decide, consolidate; since is an ISO 8601 time; limit defaults to 100 (at most 500); unconsolidated=true keeps only events not yet marked consolidated. Returns {brain, count, events, consolidation, stats}; stats repeats consolidation.

Local preview additions: until is an exclusive ISO 8601 time/date bound; since remains inclusive. before_id pages to older events using the response's next_before_id; null means no more matching events. Keep the time/kind filters unchanged while paging. count describes this page, not the full time period.

GET /api/agent-roots/{slug}/activity

Local preview, not yet deployed. Read access required. Returns complete matching Agent journal counts by UTC month/day. Query parameters: kinds (comma-separated event kinds), before_month (exclusive YYYY-MM cursor), and limit (months, default 6, maximum 12).

The response is {brain, timezone: "UTC", months, next_before_month}. A month is {month, count, counts, days} and a day is {day, count, counts}; counts maps event kinds to totals. Empty periods are omitted. Use next_before_month as before_month for the next page, with the same filters. A null cursor means there are no more matching months. Counts include the complete returned periods, independently of event-detail pagination. Use /log with since and until to read a period's events. The MCP equivalent is brain_activity.

POST /api/agent-roots/{slug}/remember

Write access. Body: {"note": "Acme wants the invoice split by team.", "topic": "acme"}; the note is at most 4,000 characters. Returns 201 with {event, consolidation}.

POST /api/agent-roots/{slug}/consolidate

Write access. Body: {"event_ids": [301, 302, 305], "memory_path": "memory/customers/acme.md"}; the file must exist under memory/. Returns {marked, skipped, memory_path, consolidation}.

GET /api/agent-roots/{slug}/load?budget=

The payload of load_brain. budget defaults to 60000 characters, with a floor of 2000.

GET /api/agent-roots/{slug}/search?q=&limit=

The payload of brain_search. limit defaults to 10, at most 50.

GET /api/agent-roots/{slug}/export

{brain, filename, files, chars, markdown}. With ?format=md, or an Accept: text/markdown header, the response is the markdown itself as a download named <slug>-brain.md.

curl -b "$COOKIE" -o support-brain.md "https://fermium.app/api/brain-roots/support/export?format=md"

GET /api/agent-roots/{slug}/proposals?status=&limit=

{brain, status, count, proposals: [proposal], can_decide}. status is pending (default), approved, partial, rejected, stale or all.

GET /api/agent-proposals/{id}

One proposal with its diff. On top of the proposal object: hunks, base_content, proposed_content, can_decide, stale, and for a pending proposal base_moved (the file changed since it was filed) and applies_cleanly.

{
  "id": 18, "status": "pending", "path": "BRAIN.md", "hunk_count": 2, "…": "…",
  "hunks": [ { "index": 0, "…": "…" }, { "index": 1, "…": "…" } ],
  "base_moved": false, "applies_cleanly": true, "stale": false, "can_decide": true
}

Each hunk has an index plus the fields of a hunk in GET /api/docs/{doc_id}/diff. After a decision each hunk also carries accepted.

POST /api/agent-proposals/{id}/decide

A person with write access, signed in with the session cookie. A bearer token is refused with a 403, whoever it belongs to.

Field Notes
accept_hunks "all" (default), "none", or a list of hunk indexes such as [0, 2]
note optional, up to 512 characters, shown to the proposer

Accepted hunks are applied to the current file and saved as a new version with the message Approved proposal #<id>. The proposal's status becomes approved, partial (some hunks), rejected (none), or stale when the accepted hunks no longer match the file, in which case nothing is written. 409 when the proposal is already decided. The proposer gets a notification. Returns the proposal with its hunks.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"accept_hunks":[0],"note":"Second change needs a source."}' \
  https://fermium.app/api/brain-proposals/18/decide

Notifications

The bell in the app. Notifications are per user. They are raised when a change is proposed to an agent the user can write to (proposal.created) and when the user's own proposal is decided (proposal.decided).

GET /api/notifications?unread=&limit=

Newest first. unread=true keeps unread ones; limit defaults to 50, at most 200.

{
  "notifications": [
    { "id": 77, "kind": "proposal.created", "title": "Proposed change to BRAIN.md in Support",
      "body": "Tone rule contradicted the brand skill. (ada@acme.com)", "link": "/app/agents/support?tab=review&proposal=18",
      "created_at": "2026-09-17T10:04:11+00:00", "read": false }
  ],
  "unread_count": 1
}

POST /api/notifications/read

Body: {"ids": [77, 78]} or {"all": true}. Marks the caller's own notifications read; ids that belong to someone else are ignored. Returns {marked, unread_count}.

Earlier agents

These endpoints serve agents of the earlier form: a curated member tree with a pinned instructions document and attached skill documents, which also drives the earlier form of the wiki. Agents that agents load as memory are under Agents as agent memory. Every member can read; writes need the workspace owner or admin role (403 otherwise). {ref} accepts the numeric id or the slug. The agent object is the one in the MCP reference.

GET /api/agents

{brains: [brain], default, can_manage, limit}. default is the default agent's slug; limit is the plan's agent limit, null when unlimited. Creates the workspace's default agent on first call if it is missing.

POST /api/agents

Body: {"name": "Support", "slug": "support", "description": "How we answer customers", "icon": "lucide:life-buoy"} (slug, description and icon optional). 201 with the agent, including its starter instructions document; 403 past the plan limit, 409 for a slug in use.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"name":"Support","description":"How we answer customers"}' \
  https://fermium.app/api/brains

GET /api/agents/{ref}

The agent object plus its member tree as the Members tab sees it: entries with broken targets flagged and dynamic folder and tag members expanded (?expand=false to skip), version, can_manage and orphans (documents a folder or tag member reaches that have no curated place). The tree shape is the one get_navigation returns.

PATCH /api/agents/{ref}

Body: any of name, slug, icon, clear_icon: true, description (null clears), is_default: true. Making an agent the default clears the flag on the previous one; is_default: false on the current default is refused because a workspace always has one. Unknown fields are a 422. Returns the agent. Which workflows the agent hands out is set through PUT /api/brains/{ref}/workflows below.

DELETE /api/agents/{ref}

{"id": 2, "slug": "support", "deleted": true}. The member tree and skill links go; the instructions document is unlinked, not deleted; documents and workflows are untouched. The default agent cannot be deleted.

PUT /api/agents/{ref}/instructions

Body: {"doc_id": "01J7…"}, a markdown document's id or alias. Returns the agent. 400 for a document that is not markdown.

GET /api/agents/{ref}/skills

{brain, skills: [{doc_id, title, alias, url, folder, updated_at, position}]}.

POST /api/agents/{ref}/skills

Body: {"doc_id": "01J8…", "position": 0} (position optional; appended when omitted). 201 with {brain, skill, skills}. Markdown documents only; at most 50 per agent.

DELETE /api/agents/{ref}/skills/{doc_id}

{brain, doc_id, detached: true, skills}.

POST /api/agents/{ref}/skills/reorder

Body: {"doc_ids": ["01J8…", "01J9…"]}. Skills are ordered as listed; any left out keep their relative order after them. 404 for an id that is not a skill of the agent. Returns {brain, skills}.

GET /api/agents/{ref}/context

The use_brain payload: {brain, instructions_markdown, skills, workflows, index, skipped, truncated, search_hint}. Query parameters: include_index (default true), max_chars (default 60000; 2000 to 2000000).

curl -b "$COOKIE" "https://fermium.app/api/brains/support/context?include_index=false"

GET /api/agents/{ref}/search?q=refund

Ranked search inside the agent. limit 1 to 50, default 10. {brain, query, count, results} as brain_search returns.

GET /api/agents/{ref}/workflows

{brain, all_workflows, workflows: [workflow]}: the workflows the agent hands out. With all_workflows: true that is every workflow in the workspace by name; otherwise the picked set in order. The workflow object is in the MCP reference.

PUT /api/agents/{ref}/workflows

Body: {"all_workflows": false, "workflow_ids": ["all-hands-deck", 4]}. workflow_ids (slugs or ids, in order) replaces the picked set when given; with all_workflows: true the picked set is kept but ignored. Returns the same shape as the GET. Recorded as a nav.changed event.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"all_workflows":false,"workflow_ids":["all-hands-deck"]}' \
  https://fermium.app/api/brains/leadership/workflows

Workflows

Background: Workflows. These endpoints serve workflows of the earlier form, defined on the Workflows page. A workflow in an agent that agents load is a file, workflows/<name>.md, read and written through /api/brain-roots/{slug}/file. Every member can read; creating, changing and deleting need the workspace owner or admin role (403 otherwise). {ref} accepts the numeric id or the slug. The workflow object and the recipe payload are in the MCP reference. Limits: Free 3, Team 25, Business and above unlimited.

GET /api/workflows

{workflows: [workflow], can_manage, limit}, by name; limit is null when unlimited. ?brain=<ref> narrows to the workflows that agent hands out, in its order, and adds brain and all_workflows.

POST /api/workflows

Body: {"name": "All-hands deck", "slug": "all-hands-deck", "description": "…", "icon": "lucide:presentation", "triggers": ["all hands deck"], "inputs": [{"name": "month", "description": "The month the deck covers", "required": true, "example": "September 2026"}], "output": {"kind": "html", "folder": "Leadership/All hands", "title_pattern": "All hands {month} {year}", "tags": ["all-hands"], "brain": "leadership", "listing": "team", "visibility": "workspace"}}. Only name is required. 201 with the workflow, including its starter WORKFLOW.md in doc; 403 past the plan limit; 400 for a slug in use or a bad value (an output.kind outside html, markdown, snippet and file, for instance).

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"name":"All-hands deck","triggers":["all hands deck"],"inputs":["month"]}' \
  https://fermium.app/api/workflows

GET /api/workflows/{ref}

The workflow object plus can_manage.

PATCH /api/workflows/{ref}

Body: any of name, slug, description (null clears), icon (null clears), triggers, inputs, output (null clears), enabled. Unknown fields are a 422. The steps are edited through the document endpoints on doc.doc_id, not here. Returns the workflow.

DELETE /api/workflows/{ref}

{"id": 3, "slug": "all-hands-deck", "deleted": true, "doc_id": "01JA…"}. The template, skill and agent links go; the WORKFLOW.md document and the templates stay as documents.

PUT /api/workflows/{ref}/templates

Body: {"templates": [{"doc_id": "01JB…", "role": "deck"}, {"doc_id": "speaker-notes", "role": "outline"}]} in order; a bare string is a document id or alias with no role; [] clears. Any document kind; at most 20. Returns {workflow, templates: [{doc_id, title, url, alias, role, kind}]}.

PUT /api/workflows/{ref}/skills

Body: {"doc_ids": ["01JC…", "deck-rules"]} in order; [] clears. Markdown documents only; at most 20. Returns {workflow, skills: [{doc_id, title, url, alias}]}.

GET /api/workflows/{ref}/recipe

The use_workflow payload without counting a use: {workflow, brain, steps_markdown, inputs, output, templates, skills, instructions_markdown, skipped, truncated, search_hint}. Query parameters: brain (slug or id; brings the agent's skills and instructions), include_templates (default true), max_chars (default 60000; 2000 to 2000000).

curl -b "$COOKIE" "https://fermium.app/api/workflows/all-hands-deck/recipe?brain=leadership"

POST /api/docs/{doc_id}/duplicate

Copy a document you may open into a new document you own. Body: {"title": "All hands October 2026", "folder": "Leadership/All hands", "tags": ["all-hands"], "brain": "leadership"}; only title is required. folder defaults to the source's, tags to none; the copy has the source's kind, files and retention, no alias, restricted visibility and private listing. 201 with the document object plus source_doc_id, and nav with the placement when brain was given (a refused placement comes back as nav: {"error": "…"} with the copy already made). Counts against the publish quota; 404 for a source you may not open; 400 for an empty title.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"title":"All hands October 2026","folder":"Leadership/All hands"}' \
  https://fermium.app/api/docs/01JB.../duplicate

Agent members and the wiki

Background: Wiki. These endpoints edit the member tree of an agent of the earlier form, which is the left sidebar of the earlier agent wiki; a folder is published as a site with PUT /api/folder-meta instead. The paths keep the older word navigations and share rows with /api/brains. Every member can read; writes need the workspace owner or admin role (403 otherwise). {id} accepts the numeric id or the slug.

GET /api/navigations

{navigations: [{id, slug, name, icon, version, entry_count, is_default, created_at, updated_at}], can_manage, limit}. limit is the plan's agent limit, null when unlimited.

POST /api/navigations

Body: {"name": "Docs", "slug": "docs", "icon": "lucide:book"} (slug and icon optional). Creates an agent with no instructions document; POST /api/brains also creates the starter BRAIN.md. 201 with the navigation; 403 past the plan limit, 409 for a slug in use.

GET /api/navigations/{id}

The editor view: every entry, broken document targets flagged broken: true, dynamic folder and tag entries expanded (?expand=false to skip), plus can_manage, version and orphans. The shape is the one get_navigation returns in the MCP reference.

GET /api/navigations/{id}/preview

The tree as a reader sees it: hidden entries removed, broken entries dropped, Sign in to see more items added. As the caller by default; ?anonymous=true for a visitor who is not signed in.

PUT /api/navigations/{id}

Replace the whole tree. Body: {"entries": [...], "version": 4} (tree is accepted as an alias of entries), or send the version as If-Match: 4. Entry shape and limits are those of set_navigation. 409 with {"detail": {"error": "...", "current": <tree>}} when the version is stale. Returns the new tree.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" -H "If-Match: 4" \
  -d '{"entries":[{"label":"Getting started","children":[{"target":"welcome"}]},{"target":"folder:Runbooks","sort":"updated"}]}' \
  https://fermium.app/api/navigations/docs

PATCH /api/navigations/{id}

Body: any of name, slug, icon, clear_icon: true. Returns the navigation.

DELETE /api/navigations/{id}

{"id": 2, "deleted": true}. The same as DELETE /api/brains/{ref}: members and skill links go, the instructions document is unlinked, documents are untouched, a wiki that published it falls back to none, and the default agent is refused.

GET /api/navigations/{id}/orphans

{navigation, orphans: [...]}: documents a folder or tag member reaches that have no curated member of their own (the Not in agent pane).

POST /api/navigations/{id}/entries

Body: {"target": "doc:01J7…", "parent": "Getting started", "label": null, "icon": null, "position": 0, "visibility": "public", "group": null, "kind": null, "collapsed": false, "show_sign_in_hint": true, "sort": null}; every field optional except that a group or link needs a label, and an entry with no target needs kind (group or divider) or a label. parent is an entry id or a group label; parent_id is accepted too. 201 with the entry plus navigation and version.

PATCH /api/navigations/{id}/entries/{entry_id}

Only the fields present change; an explicit null clears label, icon or sort. Fields: target (same kind only), label, icon, visibility, group, collapsed, show_sign_in_hint, sort. Unknown fields are a 422. Returns the entry.

POST /api/navigations/{id}/entries/{entry_id}/move

Body: {"parent_id": 12, "position": 0}; parent_id may be an entry id or a group label, null for the top level; position appends when omitted. Returns the entry.

DELETE /api/navigations/{id}/entries/{entry_id}

Removes the entry and its curated children. {"id": 31, "deleted": true, "version": 5}.

GET /api/wiki-site

{workspace, enabled, title, tagline, footer_md, navigation, navigation_id, home_doc: {doc_id, title, alias, url} | null, logo_doc_id, domain, custom_domain_id, indexable, accent, url, updated_at, can_manage}.

PUT /api/wiki-site

Only the fields present change; an explicit null clears. Fields: enabled, title, tagline, footer_md, navigation (the agent to publish, slug or id), home_doc (alias or id), indexable, domain (hostname or id of an active custom domain of the workspace), accent (#rrggbb), logo_doc (an image document). indexable: true is refused unless the workspace is on a paid plan and has an active custom domain set; it is switched off automatically if that domain stops being active. Unknown fields are a 422. Returns the same payload as GET.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"enabled":true,"title":"Acme Docs","navigation":"docs","home_doc":"welcome","domain":"docs.acme.com","indexable":true}' \
  https://fermium.app/api/wiki-site

The wiki on the content host

Not API calls, but the public surface the settings above produce. On fermiumusercontent.app every path is prefixed /w/@<workspace-slug>; on a custom domain the workspace is implied and the prefix is /w, and the bare root / redirects to /w/.

Path Serves
/w/ the home document, or an overview of the agent's members
/w/<alias-or-id> a document in the wiki; the id form 301s to the alias when one exists; not found when the agent does not reach it
/w/~<brain-slug>/... the same pages for an agent other than the published one
/w/folder/<path>, /w/tag/<tag> the listing behind a dynamic entry
/w/r/<root-slug>/<folder path>/ a published folder: its index page and its children; /w/r/<root-slug>/ is the site home. <root-slug> is a shared root, or ~<user id> for a tree in My documents
/w/r/<root-slug>/<folder path>/<alias-or-id> a document inside a published folder; not found when it is outside every published folder the reader may see
/w/r/<root-slug>/search?q=, /w/r/<root-slug>/sitemap.xml search and sitemap of that published tree
/w/search?q= ranked search within the wiki, scoped to what the reader may see
/w/sitemap.xml public pages with lastmod, only on an indexable site; 404 otherwise
/w/opensearch.xml the browser search-box description
/robots.txt Disallow: / everywhere except an indexable custom domain, where it allows /w/, disallows /d/ and /x/, and names the sitemap

Wiki pages carry X-Robots-Tag: noindex, nofollow and the matching meta tag unless the site is indexable and the page is public; pages rendered for an identified viewer are Cache-Control: private, no-store.

Background: Workspace policy, Expiry and retention.

GET /api/policy

Any member.

{
  "allowed_visibilities": ["workspace", "password"],
  "min_retention_days": null,
  "max_retention_days": 365,
  "secret_scan": "warn",
  "publishing": "public",
  "ip_allowlist": null,
  "enabled": true,
  "plan": "business",
  "ip_allowlist_available": false,
  "legal_hold_available": false,
  "updated_at": "2026-09-15T09:00:00+00:00",
  "can_edit": true
}

PUT /api/policy

Owner or admin, Business and above (402 otherwise). Body: any of allowed_visibilities (non-empty list or null), min_retention_days, max_retention_days (>= 1 or null), secret_scan (off, warn, block), publishing (public, signed_in, off: whether folders may be published as sites, and for whom), ip_allowlist (list of CIDRs or null; Enterprise). Only the keys you send change. Returns the same payload as GET.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"allowed_visibilities":["workspace","password"],"max_retention_days":365,"secret_scan":"block"}' \
  https://fermium.app/api/policy

GET /api/docs/{doc_id}/policy-state

The document's owner, or a workspace owner or admin. {doc_id, legal_hold, legal_hold_available, can_set_legal_hold, retention_days, expires_at, expiry_warned_at}.

POST /api/docs/{doc_id}/legal-hold

Enterprise. Body: {"on": true} or {"on": false}. The document's owner, or a workspace owner or admin. Returns {doc_id, legal_hold}.

POST /api/docs/{doc_id}/keep

Owner only. Sets retention to 0, or to the policy maximum when one exists.

{ "doc_id": "01J7…", "retention_days": 0, "expires_at": null, "kept_forever": true }

GET /u/keep/{token}

The one-click link from the expiry warning email. Not an API call: it renders a small confirmation page and needs no sign-in. The token is bound to the document and its expiry at the time of the email.

Audit log

Business and above; owners and admins see the workspace, members see events about their own documents. Background: Audit log.

GET /api/audit

Query parameters: action, actor, target_type, target_id, from, to (ISO-8601 or YYYY-MM-DD), limit (default 100, max 500), before_id (cursor).

curl -b "$COOKIE" "https://fermium.app/api/audit?action=doc.visibility&from=2026-09-01&limit=50"
{
  "events": [
    { "id": 9127, "action": "doc.visibility", "actor": "ada@acme.com", "actor_user_id": 3, "target_type": "doc", "target_id": "01J7…", "workspace_id": 7, "meta": { "title": "Q3 proposal", "visibility": "workspace", "folder": "Customers/Acme" }, "ip": "203.0.113.9", "created_at": "2026-09-15T14:02:11+00:00" }
  ],
  "next_before_id": null,
  "scope": "workspace"
}

scope is workspace or own. next_before_id is set when a page was full; pass it as before_id for the next page.

GET /api/audit/actions

{"actions": [...]}: every known action name plus any present in the log.

GET /api/audit/export.csv

Same filters as GET /api/audit except limit and before_id. Streams text/csv as an attachment named audit-log.csv, newest first, columns id, created_at, action, actor, target_type, target_id, ip, meta, up to 100,000 rows.

curl -b "$COOKIE" -o audit-log.csv "https://fermium.app/api/audit/export.csv?from=2026-09-01&to=2026-09-30"

Webhooks

Business and above, owner or admin. The delivery contract is on Webhooks.

GET /api/webhooks

{"webhooks": [{id, url, events, active, created_at, secret_set}]}.

POST /api/webhooks

Body: {"url": "https://example.com/hooks/fermium", "events": ["doc.*"]}. 201 with the webhook and, only in this response, secret. 400 for a URL that is not http(s) or resolves to a non-public address.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/fermium","events":["doc.publish","doc.update","member.join"]}' \
  https://fermium.app/api/webhooks
{ "id": 4, "url": "https://example.com/hooks/fermium", "events": ["doc.publish", "doc.update", "member.join"], "active": true, "created_at": "2026-09-15T10:00:00", "secret_set": true, "secret": "whsec_…" }

PATCH /api/webhooks/{id}

Body: any of url, events, active. Returns the webhook.

DELETE /api/webhooks/{id}

{"deleted": true, "id": 4}. Deliveries are deleted with it.

POST /api/webhooks/{id}/test

{"queued": true, "delivery_id": 41, "event_id": 9130}.

GET /api/webhooks/{id}/deliveries?limit=50

{"deliveries": [{id, webhook_id, event_id, action, status, attempts, last_status_code, last_error, next_attempt_at, created_at, delivered_at}]}, newest first, at most 200.

Account: export and deletion

Every plan. Background: Deleting your account.

GET /api/export

Streams fermium-export.zip: index.json at the root, and one directory per live document you own containing meta.json and every file of the current version.

curl -b "$COOKIE" -o fermium-export.zip https://fermium.app/api/export

DELETE /api/account

Body: {"confirm": "you@acme.com"}, your email exactly. Soft-deletes every document you own, disables the account, clears the session cookie, and schedules the purge.

{ "deleted": true, "email": "you@acme.com", "purge_at": "2026-09-22T14:02:11+00:00", "grace_days": 7 }

400 if confirm does not match; 409 if you own a workspace that still has other active members.

Members and invites

Background: Members and roles.

GET /api/members

Any member.

{
  "members": [
    { "id": 3, "email": "ada@acme.com", "role": "owner", "joined": "2026-09-01T09:00:00+00:00", "disabled": false, "last_sign_in": "2026-09-15T08:12:00+00:00", "sso": false, "scim": false, "is_me": true }
  ],
  "me": { "id": 3, "email": "ada@acme.com", "role": "owner" },
  "workspace": { "id": 7, "slug": "acme", "name": "Acme", "plan": "team", "member_cap": 25, "pending_invites": 1, "seats_left": 23 }
}

PATCH /api/members/{user_id}

Owner only. Body: {"role": "admin"} (owner, admin or member). Returns the member row. 400 when it would leave the workspace without an owner.

DELETE /api/members/{user_id}

Owner or admin (only an owner may remove an owner; the last owner cannot be removed; you cannot remove yourself). The person moves to a new personal workspace with the documents in their My documents. Documents they put in General or a shared root belong to the root and stay; their personal entries on root access lists are removed.

{ "removed": 9, "moved_to": { "id": 31, "slug": "bo" } }

POST /api/members/transfer

Owner only. Body: {"user_id": 9}. Returns {owner, previous_owner} member rows; you become an admin.

GET /api/invites

Owner or admin. {"invites": [{id, email, role, created_at, expires_at, accepted_at, url}], "member_cap": 25, "members": 4}; open invites only.

POST /api/invites

Owner or admin. Body: {"email": "bo@acme.com", "role": "member", "group_ids": [5]} (member or admin). Optional native-group assignments remain pending until acceptance. 201 with the invite row including its url. 409 if the address is already a member or already invited; 402 if members plus pending invites would exceed the cap.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"email":"bo@acme.com","role":"member"}' https://fermium.app/api/invites

PATCH /api/invites/{id}/groups and POST /api/invites/{id}/resend

Managers. PATCH takes {"group_ids": [5, 8]} and updates pending assignments. POST rotates the invitation token, returns the new URL once, and queues email. Ordinary invitation lists return url: null, group assignments and delivery status. No group membership or file access begins until the invited account accepts.

DELETE /api/invites/{id}

{"revoked": id}.

GET /u/invite/{token}

The accept link (browser). Signed in with the invited address: joins and redirects into the app. Signed out: starts sign-in and applies the invite afterwards. Expired, used, or a different account: redirects into the app with a reason.

Groups and access

Groups require Team or above. Individual and whole-workspace document/folder grants work on every plan. Background: Members and roles.

GET /api/groups

Any member. {"groups": [{id, name, slug, source, description, member_count, pending_count, members: [{id, email, role}], created_at}], "can_manage": false}.

POST /api/groups

Owner or admin. Body: {"name": "Design"}. 201 with the group; 409 if the slug exists.

PATCH /api/groups/{id}

Body: {"name": "Product design", "description": "What this group does"}. Renames while preserving the existing handle and grants. Native groups only; Google, SCIM and IdP groups reject local changes.

DELETE /api/groups/{id}

{"id": 5, "deleted": true}. Grants naming the group are removed.

POST /api/groups/{id}/members and DELETE /api/groups/{id}/members?email=...

Add (body {"email": "bo@acme.com"}, 201) or remove a member. Returns the group. 404 if the address is not a member of the workspace.

GET /api/groups/{id} and PUT /api/groups/{id}/members

Managers. GET returns the group, pending invitations, recent changes and only resource grants the caller can inspect. PUT takes {"emails": ["bo@acme.com"], "present": true} for an atomic bulk add, or false to remove (maximum 100). Every address must be an active member of the same workspace.

Google directory and MCP sessions

GET /api/directory/google returns connection health and selected groups. POST /connect starts administrator OAuth; GET /groups lists available Google groups; POST /groups takes {"group_id": "<Google ID>", "mode": "sync"} or "import"; POST /sync queues a refresh. DELETE /api/directory/google disconnects and immediately pauses access through synced groups. Managers, Team and above. PUT /policy with {"google_only": true} is Enterprise only. See Google group behavior.

GET /api/mcp-session-policy reports the maximum and default connection duration. Enterprise managers can PUT {"max_days": 30} (1–365). The limit applies to existing OAuth grants as well as new consent. See Authentication.

GET /api/docs/{doc_id}/access

Any user who can see the document. {doc_id, owner: {id, email}, my_level, can_manage, narrowed, grants: [grant]}. A grant is {id, doc_id, folder, folder_owner_id, subject_type, subject_id, subject, subject_label, level, created_by, created_at, inherited}.

POST /api/docs/{doc_id}/access

Personal document owner or the applicable shared-space sharing manager. Body: {"subject_type": "user" | "group" | "workspace", "subject": "<email> | <group slug> | null", "level": "view" | "comment" | "edit"}. 201 with the grant; sharing an existing subject again changes its level.

curl -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"subject_type":"group","subject":"design","level":"comment"}' \
  https://fermium.app/api/docs/01J7ZK3N9QW0X4B2C6D8E0F1G2/access

DELETE /api/access/{grant_id}

The document or folder owner, the grant's creator, or a workspace admin. {"id": 12, "deleted": true}.

GET /api/folders/access?path=Customers/Acme

Grants on one of your folders. {path, grants: [grant]}.

POST /api/folders/access?path=Customers/Acme

Folder owner only. Same body as a document grant; applies to every document of yours at or under the path. The root cannot be shared. 201 with the grant.

DELETE /api/folders/access?grant_id=...

Remove a folder grant. {"id": 9, "deleted": true}.

Document access requests

Existing signed-in workspace members can request view access to a known Restricted or legacy Workspace link. All routes use the current identity and workspace permissions. Requests never reveal the locked document's title, content or owner to the requester. See Access.

Method and path Body or query Result
POST /api/access-requests {slug, message?}; message up to 1,000 characters 201 with own request status; repeated pending requests reuse it
GET /api/access-requests/status ?slug=... {status, can_request, poll_after, expires_at?}
GET /api/docs/{doc_id}/access-requests None Sharing managers only: {requests: [...], email_available}; up to 100, pending first
POST /api/access-requests/{request_id}/decision {decision: "approve" | "deny"} Sharing managers only: {status: "approved" | "denied", already_decided}

Requester status is none, pending, granted, denied, expired, revoked or ineligible. Poll every five seconds only while pending and visible; back off after errors. Creating requests is limited to 10 calls per account per hour across REST and MCP; REST status checks allow 120 calls per account per minute. Requests last 30 days; retrying a denial requires waiting 24 hours.

Approval grants view access to the exact document, preserving any greater existing access. Denial does not revoke a separate grant. Decisions and grants commit together. An old decision cannot regrant revoked access. Moving a document or changing its owner, workspace or visibility invalidates pending requests.

Notification emails contain private, signed /auth/access-request/{ticket} links and a document permissions link. GET displays a review screen; POST confirms the decision. These email capabilities expire 30 days after the request and recheck current manager permission, requester membership and network policy. Do not log or forward them.

Comments

Paid plans. Background: Members and roles.

GET /api/docs/{doc_id}/comments

include_resolved=true to include resolved ones. {doc_id, can_comment, open_count, comments: [{id, doc_id, author: {id, email}, body, path, anchor, resolved, resolved_at, created_at, mine}]}.

POST /api/docs/{doc_id}/comments

Body: {"body": "...", "path": "source.md", "anchor": "pricing"} (path and anchor optional; body up to 8,000 characters). 201 with the comment. 403 if you can read the document but not comment on it.

PATCH /api/comments/{id}

Body: {"body": "..."} (author only) and/or {"resolved": true} (anyone who may comment). Returns the comment.

DELETE /api/comments/{id}

Author or document owner. {"id": 31, "deleted": true}.

SSO configuration

Business and above, owner or admin. Background: SSO and SCIM.

GET /api/sso

{
  "available": true,
  "plan": "business",
  "sso": { "issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0", "client_id": "…", "client_secret_set": true, "domains": ["acme.com"], "enforced": false, "updated_at": "2026-09-15T09:00:00+00:00" },
  "redirect_uri": "https://fermium.app/auth/sso/callback",
  "login_url": "https://fermium.app/auth/sso/acme"
}

sso is null until configured.

PUT /api/sso

Body: issuer (https), client_id, client_secret (required the first time; omit later to keep), domains (at least one; each must have a member on it), enforced. 402 below Business, 400 for a domain with no member, 409 for a domain another workspace routes to its provider.

curl -X PUT -b "$COOKIE" -H "Origin: https://fermium.app" -H "Content-Type: application/json" \
  -d '{"issuer":"https://acme.okta.com","client_id":"0oa…","client_secret":"…","domains":["acme.com"],"enforced":false}' \
  https://fermium.app/api/sso

DELETE /api/sso

Turns SSO off. {"deleted": true}; 404 if not configured.

GET /auth/sso/lookup?email=...

Public, rate limited per IP. {"sso": false} or {"sso": true, "url": "https://fermium.app/auth/sso/acme?next=%2Fapp", "enforced": false}.

GET /auth/sso/{workspace_slug} and GET /auth/sso/callback

The sign-in entry point and the provider's redirect target. Browser flows, not API calls.

SCIM tokens

Enterprise, owner or admin. GET, POST and DELETE /api/scim/tokens; the token is returned once at creation. Details and the /scim/v2 surface are on SCIM.

Folders

A folder is where a document lives, one path per document. Folders in My documents belong to your account; folders in General and shared roots belong to the root, and the endpoints below take root to work there (edit on the folder is needed to create, rename or delete). Path rules: segments of 1 to 64 letters, digits, spaces, ., _ or -, starting with a letter or digit and not ending in .; / between segments; at most 8 levels and 512 characters; case preserved. A bad path is a 400 with the offending segment in error. Background in Folders, tags, and stars.

Folder objects:

{ "path": "Customers/Acme", "name": "Acme", "parent": "Customers", "doc_count": 3, "explicit": true }

doc_count is documents directly in the folder. explicit is true for folders created on their own; folders implied by a document's path are listed with explicit: false.

GET /api/folders

Every folder you have, including ancestors implied by document paths, sorted by path case-insensitively.

curl -b "$COOKIE" https://fermium.app/api/folders

root=<slug> returns {root, folders} for a shared root instead: only folders you can see, each row also carrying icon, description, index_doc_id, view_mode, published, my_level, root, shared: true and restricted (the folder has its own access list). root=~ returns your own tree with the same extra fields.

include_team=1 adds the folders, and their ancestors, of team-listed and shared documents you may open, which is what the file browser's tree shows. Those rows carry shared: true at every depth that holds such a document, and doc_count counts only documents you may open. The rule is the one for GET /api/docs?view=all.

{
  "folders": [
    { "path": "Customers", "name": "Customers", "parent": "", "doc_count": 0, "explicit": false },
    { "path": "Customers/Acme", "name": "Acme", "parent": "Customers", "doc_count": 3, "explicit": true },
    { "path": "Planning", "name": "Planning", "parent": "", "doc_count": 1, "explicit": false }
  ]
}

POST /api/folders

Create an empty folder. Body: {"path": "Customers/Globex"}, plus "root": "<slug>" to create it in a shared root. Returns 201 with the folder object; creating a path that already exists returns the existing folder. 400 for an empty path.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"path":"Customers/Globex"}' \
  https://fermium.app/api/folders

PATCH /api/folders

Rename or move a folder and everything under it (documents, subfolders, stars, and in a shared root the folder settings, access lists and shortcuts). Body: {"path": "Clients", "new_path": "Customers"}, plus "root": "<slug>" for a folder of a shared root (edit on the folder and on the new parent). Returns the folder object at new_path. Document URLs do not change. Renaming onto an existing path merges the two. 400 for the root, an empty new_path, or a new_path inside path (cannot move a folder inside itself).

curl -X PATCH -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"path":"Clients","new_path":"Customers"}' \
  https://fermium.app/api/folders

DELETE /api/folders?path=...&force=...

Delete a folder; add root=<slug> for a folder of a shared root, whose own settings and access list go with it. Without force the folder must be empty; otherwise 400 with folder is not empty; move its contents first or pass force=true. With force=true, its documents and subfolders move to the parent (the root for a top-level folder) and the folder is removed. Documents are never deleted. 400 for the root.

curl -X DELETE -b "$COOKIE" -H "Origin: https://fermium.app" \
  "https://fermium.app/api/folders?path=Scratch&force=true"
{ "path": "Scratch", "deleted": true }

Stars

A star is a personal shortcut to a document or folder you can access. Stars are private to you and do not change the document. The app shows them at the top of the sidebar.

Star objects carry either doc_id (with the full document under doc) or folder:

{
  "stars": [
    { "id": 4, "doc_id": "01J7ZK3N9QW0X4B2C6D8E0F1G2", "folder": null, "created_at": "2026-09-16T09:12:40.201133", "doc": { "doc_id": "01J7ZK3N9QW0X4B2C6D8E0F1G2", "title": "Q3 roadmap", "folder": "Planning/2026", "...": "..." } },
    { "id": 5, "doc_id": null, "folder": "Customers/Acme", "created_at": "2026-09-16T09:13:02.887410", "doc": null }
  ]
}

GET /api/stars

Your stars, oldest first. A star whose document has since been deleted is dropped from the list and removed.

curl -b "$COOKIE" https://fermium.app/api/stars

POST /api/stars

Body: {"doc_id": "..."} or {"folder": "Customers/Acme"}, exactly one. With folder, "root": "<slug>" stars a folder of a shared root ("folder": "" is the root itself); such a star carries root: {id, slug, name, kind} and is hidden while you cannot open that place. Returns 201 with the star object; starring something already starred returns the existing star. 404 for a document you cannot access; 400 for the root folder or an empty body (pass doc_id or folder).

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"folder":"Customers/Acme"}' \
  https://fermium.app/api/stars

DELETE /api/stars/{star_id}

Remove a star. Response: {"id": 5, "deleted": true}. 404 if the star is not yours.

Stars follow folder renames. When a folder is deleted with force, a star on it moves to the parent path, or is removed if the parent is the root.

Tokens

GET /api/tokens

{
  "tokens": [
    {
      "id": 12,
      "name": "ci-publisher",
      "prefix": "3f9a1c2b",
      "created_at": "2026-09-15T10:00:00",
      "expires_at": "2027-09-15T10:00:00+00:00",
      "last_used_at": null,
      "revoked": false
    }
  ]
}

Tokens issued through the OAuth flow appear here too.

POST /api/tokens

Mint a static token. Body: name (string), ttl_days (0 to 3650; default 365; 0 means never). Returns 201 with the plaintext once:

{ "id": 13, "name": "ci-publisher", "prefix": "8d2e77a1", "token": "dsk_8d2e77a1_...", "expires_at": "2027-09-15T10:00:00" }

Rate limited per IP.

POST /api/tokens/{token_id}/revoke

Revokes the token and, if it came from an OAuth grant, the grant. Response: {"revoked": true}.

Workspace and billing

GET /api/billing/workspace

{
  "workspace": {
    "id": 7,
    "slug": "acme",
    "name": "Acme",
    "plan": "team",
    "is_paid": true,
    "viewer_domains": ["acme.com"],
    "members": 4,
    "storage_addon_units": 0,
    "has_billing_account": true
  },
  "storage": { "used": 734003200, "cap": 107374182400 },
  "billing_enabled": true,
  "pricing_url": "https://fermium.app/pricing"
}

storage.cap is null on Enterprise.

PATCH /api/billing/workspace

Body: name and/or viewer_domains (list). Each domain must have at least one member on it, must not be a public email provider (400), and must not be claimed by another workspace (409). Returns the same payload as GET.

POST /api/billing/checkout

Body: {"price": "team_monthly"}. Valid keys: team_monthly, team_yearly, business_monthly, business_yearly, storage_50gb_monthly. Returns {"url": "https://checkout.stripe.com/..."}. 503 if billing is not configured; 404 for an unknown price.

POST /api/billing/portal

Returns {"url": "https://billing.stripe.com/..."}. 404 with "no billing account yet" until a plan has been started.

POST /api/billing/webhook

Stripe calls this; you do not. The request signature is verified and the workspace plan is derived from the subscription. Responds {"received": true, "type": "..."}.

Custom domains

All responses share one payload:

{
  "domains": [
    {
      "id": 3,
      "hostname": "docs.acme.com",
      "status": "pending",
      "error": "expected CNAME cname.fermium.app; saw nothing; TXT _fermium record missing or wrong",
      "is_default": false,
      "records": [
        { "type": "CNAME", "name": "docs.acme.com", "value": "cname.fermium.app" },
        { "type": "TXT", "name": "_fermium.docs.acme.com", "value": "fm-verify-9c1e4b7a2d3f0a6b5c8d9e0f" }
      ],
      "verified_at": null,
      "last_checked_at": "2026-09-15T12:00:00",
      "url_example": "https://docs.acme.com/d/<slug>/"
    }
  ],
  "limit": 1,
  "plan": "team",
  "cname_target": "cname.fermium.app",
  "link_host": null,
  "provisioning": true
}

status is one of pending, dns_ok, issuing, active, error. limit is null on Enterprise.

GET /api/domains

List domains and the plan limit.

POST /api/domains

Body: {"hostname": "docs.acme.com"}. Returns 201 with the payload. 402 when the plan has no domains left, 400 for an apex domain, IP address or Fermium hostname, 409 if the hostname is already registered.

curl -b "$COOKIE" -H "Origin: https://fermium.app" \
  -H "Content-Type: application/json" -d '{"hostname":"docs.acme.com"}' \
  https://fermium.app/api/domains

POST /api/domains/{domain_id}/verify

Run the DNS check. On first success the certificate and route are provisioned and status moves to issuing; call again to poll until active. Failures leave status pending with the reason in error.

POST /api/domains/default

Body: {"domain_id": 3} to make an active domain the link host, or {"domain_id": null} to go back to the shared host. 400 if the domain is not active. You rarely need the first form: a domain that turns active while the workspace has no link host becomes the link host on its own, and link_host in the payload shows the result.

DELETE /api/domains/{domain_id}

Remove the domain, its certificate and route. Clears the link host if it was this domain.

Health

GET /healthz returns {"status": "ok"} on every host and is suitable for uptime monitors. GET /.well-known/security.txt on fermium.app is the RFC 9116 contact file; see Responsible disclosure. Readiness probes used by our own infrastructure are not part of the public API.

File activity and traffic

GET /api/activity/summary, /api/activity/events and /api/activity/status return permission-filtered file/folder analytics and change cursors. See Activity API and MCP for parameters, pagination and retention.