Lists of cards, and properties your team declares.
A workspace holds lists. A card has a readable key, a status, a place in a hierarchy — and then whatever properties your team declared, from one registry shared by every list. No built-in assignee or due date imposed on a content base; no ten copies of “Priority” drifting apart.
A saved view is a filter, a sort and a group-by — a piece of data, not a query someone typed. The board below and the table after it are the same definition, looked at two ways.

- properties
- Ten types, reused across lists: text, number, select, multi-select, date, checkbox, url, person, several people, file. Validated when written, so a badly typed value is refused at the door instead of breaking every view that sorts on it later.
- page bodies
- Headings, lists, to-dos, quotes, callouts, code, dividers and images. Marks are named — bold, italic, code, strike, link — so the format cannot carry HTML and there is nothing in it to sanitise.
- hierarchy
- Cards nest. A subtree or a card's ancestors is one request, and moving a card moves its subtree rather than orphaning it. A card can sit in several lists without leaving the one it was born in.
- files
- Attachments and image blocks carry a file id, never a URL. The API serves the bytes from a scoped route: no public bucket, no signed link to forward by mistake.


Your site reads what your team just wrote.
There is no publish step that copies content somewhere else, and no second store to keep in sync. Every screen of the app goes through the same API you get a key for — so the API cannot fall behind the product, because it is the product.
The spec is generated from the annotated routes and committed with them. Generate your own types from it and a renamed property becomes a build error on your side, not a blank section in production.
- protocol
- REST — and never GraphQL. That one is settled, not pending.
- spec
- OpenAPI at /openapi.json, with an interactive reference at /api/docs.
- credential
- an API key per workspace, with its own role — viewer reads, member writes. Revocable one by one, last use dated.
- pagination
- keyset cursors everywhere. No offset, so no page that shifts under you.
- isolation
- Postgres row-level security under every query. A workspace you are not in answers 404, not 403.
npx openapi-typescript https://cms.spunto.net/openapi.json \
--default-non-nullable false -o cms.d.tsimport createClient from "openapi-fetch"
import type { paths } from "./cms"
const api = createClient<paths>({
baseUrl: "https://cms.spunto.net/api",
// an API key: spk_…, scoped to one workspace
headers: { Authorization: `Bearer ${SPUNTO_CMS_TOKEN}` },
})
// A view is data: a filter tree, sorts, an optional group-by.
const query = "/workspaces/{workspaceId}/collections/{collectionId}/query"
const { data } = await api.POST(query, {
params: { path: { workspaceId, collectionId } },
body: {
definition: {
filter: { op: "eq", field: { fieldId: STAGE }, value: "published" },
sort: [
{ field: { fieldId: "sys:created_at" }, direction: "desc" },
],
},
limit: 20,
},
})
data?.rows // "DOC-12", its title, its typed properties
data?.nextCursor // keyset — there is no page number to ask forSTAGE is the id of a select your team declared — nothing but the title, the body and the status is built in. The full walk-through, from an empty account to this call, is the quickstart.
A script is just another member.
Content does not have to be typed by hand. A script or an agent creates cards, sets typed properties, writes a structured body and runs a bulk update over a view — through the same routes and the same checks as a person’s session.
And whatever they write, something else can follow. Every change appends to the workspace’s event log inside the transaction that made it, so an event exists if and only if the change committed.
- event log
- who archived that card, what moved this week — readable by cursor, so a consumer that was down catches up instead of losing events
- webhooks
- fired from that log, with a delivery history and a resume after failures
- apps
- installable OAuth apps: a third party gets its own identity, bounded by a role and scopes the workspace granted
curl -X POST "$CMS/api/workspaces/$WS/collections/$LIST/items" \
-H "Authorization: Bearer $SPUNTO_CMS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Release notes — v0.8",
"properties": { "stage": "draft", "tags": ["release"] },
"blocks": { "blocks": [
{ "type": "heading", "level": 2,
"text": [{ "text": "What changed" }] },
{ "type": "bulleted",
"text": [{ "text": "Keyset pagination", "bold": true }] },
{ "type": "todo", "checked": false,
"text": [{ "text": "Screenshot the board" }] }
] }
}'$CMS is https://cms.spunto.net; stage and tags are keys from your registry. A value that cannot be read as the declared type is refused here, at the write, rather than breaking every view that sorts on it later.
Meant to replace Notion and ClickUp.
Not there yet — here is the gap.
The aim is the tool a team plans and writes in all day, whose content is also data a site and an agent can read. A page that only lists features wastes the time of whoever believes it, so the other list is right beside it — taken from the product’s own limits page.
there today
- views
- table, list and kanban over the same saved definition — fields shown per view, columns in your order, drag to reorder
- cards
- a readable key, a status, subtasks to any depth, several assignees, an icon, and a block body with a slash menu
- navigation
- a sidebar of lists and groups that persists between pages, and ⌘K over card titles and list names
- import
- a Notion database becomes a list in one command — replayable, so the second run writes nothing
- sign-in
- email and password or Google, per workspace roles (owner, admin, member, viewer), invitations
not yet
- calendar
- the API and the view compiler serve it; the web app has no screen for it yet
- search
- titles and list names only — no full-text index over card bodies
- publishing
- no draft / published split — a select property your site filters on is the honest answer today
- docs pages
- every page is a card in a list; there is no free-standing wiki tree yet
- language
- the app's interface is in French; the API, its docs and this page are in English
- mobile
- a native iOS client lives in the repo and talks to the same spec — not on the App Store