Spunto CMSa workspace with an API

A workspace to write in.
An API that serves it.

Most teams end up running two things: somewhere pleasant to write and organise work, and a content API clean enough to feed a site or an agent. Then they spend a year syncing them. Spunto CMS is one thing — lists of cards, typed properties, block pages and saved views on one side; the same rows over REST on the other. Not an export. The same rows.

table · list · kanbanblock pagesREST + OpenAPIwritable by agents
one request · any workspace key
$ curl https://cms.spunto.net/api/workspaces \
  -H "Authorization: Bearer spk_…"

an API key from your workspace settings — or skip the API and sign up at cms.spunto.net

01 — The workspace

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.

Spunto CMS: the Sprint 42 list as a kanban by status — To do, In progress, Done — with cards showing priority, effort, team, tags and assignee
the real app, on its demo seed — a kanban by status, and four other saved views one tab away
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.
The same list as a table sorted by priority, with keys, titles, subtasks indented under their parent, statuses and typed columns
the same list, as a table — subtasks under their parent
A card open in the side panel: a block body with bullets, to-dos, a quote, a callout and a SQL code block, subtasks below, and typed properties on the right
a card: a block document, subtasks, and the registry's properties
02 — The headless half

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.
once — types from the live spec
npx openapi-typescript https://cms.spunto.net/openapi.json \
  --default-non-nullable false -o cms.d.ts
read-published.ts
import 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 for

STAGE 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.

03 — Written by agents, too

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
create-card.sh
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.

04 — Where it's going

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

↳ also from Spunto