Noon stores three kinds of content: pages, posts, and entities of the content types you define yourself. This page explains what each one is, how drafts and publishing work, and the limits you will hit as an author.
Pages
A page is a slug, a title, and a layout of typed blocks. The layout is an array of blocks, and each block has a type and its own props (a hero section, a rich text block, an image, and so on). Create one with create_page, edit it with update_page. Ask describe_schema for the current list of block types and their props: it stays accurate as new blocks ship.
Posts
A post is a slug, a title, an MDX/Markdown body, and frontmatter. The body is stored exactly as written, byte for byte, and compiled to HTML only when a page requests it. Frontmatter carries editorial metadata such as description and tags. Create one with create_post, edit it with update_post.
User-defined content types
A content type is a definition you write yourself: {name, label, fields}. Once defined, you create and edit entities of that type through manage_content_type (action: 'create-entity' / 'update-entity'), and they follow the same draft-and-publish lifecycle as pages and posts.
{
"action": "define-type",
"input": {
"name": "recipe",
"label": "Recipe",
"fields": [
{ "name": "servings", "label": "Servings", "kind": "number", "min": 1, "max": 50 },
{ "name": "body", "label": "Body", "kind": "richtext" }
]
}
}
name is a lowercase, kebab-case machine name. It cannot be page, post, or template: those are reserved. A definition needs at least one field.
Field kinds
Every field has a name, a label, and a kind. The kind decides what the field stores and which options it takes.
| Kind | Stores | Options |
|---|---|---|
string | Short text | maxLength (required, 1 to 4096); optional enum, a closed list of allowed values |
number | A number | Optional min; optional max |
boolean | true or false | None |
timestamp | A date/time value | None |
reference | The id of an entity of another content type | target (required): the name of the content type this field points at. target can name a content type you defined, or a built-in kind (page, post, template), or the type being defined itself (a self-reference) |
richtext | Long-form MDX/Markdown text, stored verbatim | None |
A richtext field only renders Markdown image syntax (). An HTML <img> tag is parsed as MDX and silently dropped: the write succeeds, but the image never appears.
Once a type has entities, you cannot narrow a field in a way that would reject data already stored under it: lowering a maxLength, tightening min/max, dropping an enum member, removing a field, or changing a field's kind are all refused. Widening is always free. There is no migration path for existing entities in this version, so plan field bounds generously before you have real content.
Routable and the delivery policy
Every content type carries two delivery settings, written alongside the definition:
routable: a boolean. In the current version it is advertisement only: it tells a reader "this type is meant to be addressable," but it does not gate anything. Every published entity of a custom type already gets a/{slug}URL regardless of this flag.publicRead/publicFields: the actual authorization gate for the anonymous read surface.publicRead: false(the default) means nothing of this type is exposed publicly. SettingpublicRead: truerequires you to also setpublicFieldsto an explicit, ordered list of field names to expose ([]is legal and means "expose nothing but the entity's identity"). You cannot setpublicRead: truewith nopublicFieldsat all.
{
"action": "update-type",
"input": {
"expectedRevision": 1,
"name": "recipe",
"label": "Recipe",
"fields": [ /* ... */ ],
"publicRead": true,
"publicFields": ["servings"]
}
}
A field named in publicFields must exist on the definition being written in the same call. Changing the policy revalidates every already-published entity of that type against the new projection size, and a publish revalidates the entity being published against the current policy: an oversized payload is refused, never truncated.
Taxonomies and terms
A taxonomy is not a separate concept. A taxonomy is a content type, and a term is an ordinary entity of that type. Any content type can serve as a taxonomy: there is no is_a_taxonomy flag to set.
To tag one entity with another, assign a term with manage_content_type (action: 'assign-terms'), naming the entity, the term, and the term's own content type (taxonomyId):
{
"action": "define-type",
"input": { "name": "topic", "label": "Topic", "fields": [{ "name": "description", "label": "Description", "kind": "string", "maxLength": 280 }] }
}
{
"action": "create-entity",
"input": { "type": "topic", "slug": "woodworking", "title": "Woodworking", "data": { "description": "Hand tools and joinery." } }
}
That call returns the term's id. Assign it to a recipe entity's id:
{
"action": "assign-terms",
"input": {
"assignments": [
{ "entityId": "<recipe entity id>", "termId": "<topic entity id>", "taxonomyId": "topic" }
]
}
}
taxonomyId must equal the term's own content type name, or the assignment is refused. Re-assigning a pair you already assigned succeeds and is reported as unchanged, never an error: an agent retrying a batch never has to diff first. Remove an assignment with action: 'unassign-terms', naming just entityId and termId. A batch of assignments or unassignments is capped at 100 pairs per call.
A term follows the same draft/publish lifecycle as any other entity of its type: publish it, unpublish it, roll it back, exactly like a recipe.
Drafts and publishing
Every write to a page, a post, or a custom entity creates or replaces a pending draft. Nothing is live until you call publish. This applies uniformly:
- Create writes an unpublished row with the authored content in
draft. - Update (
update_page/update_post/action: 'update-entity') overwrites the pending draft. It does not touch whatever is currently published. - Publish strictly re-validates the stored draft, promotes it to the published columns, and clears the draft.
Two counters track this, and they answer different questions:
revisionis the compare-and-swap token. It increments on every mutation: create, update, discard, publish, unpublish, rename, rollback. Every write that targets an existing row must pass the currentrevisionasexpectedRevision.versionis the published-version counter. It increments only when a publish actually happens. An entity that has never been published is atversion: 0.
If your expectedRevision is stale, the write fails with a revision_conflict:
{
"error": {
"code": "revision_conflict",
"message": "... was modified concurrently, retry with the current revision",
"currentRevision": 4,
"currentVersion": 2
}
}
Recover by reading the entity back with query (or from the error itself, which already carries currentRevision), then retrying your write with that value as expectedRevision. The conflict never partially applies your write: either the whole mutation lands, or none of it does.
Versions and rollback
Every successful publish stores the newly published snapshot as a numbered version, so list_versions always returns real, retrievable history:
list_versionsreturns the ordered history headers (versionNo,createdBy,createdAt) for one entity. An entity that has never been published simply has an empty history; that is not an error.diff_versionscomputes a JSON path-diff between two stored versions, including "version equals the current published state" as a valid comparison target.rollbackrestores a stored version's snapshot into the entity's draft. It requiresexpectedRevision, exactly like any other draft write.
Rollback never touches the live site directly. It only replaces the pending draft; the previously published content stays live until you call publish again. Rolling back to the entity's own current version is a valid, meaningful call: it re-loads the live content into the draft, for example so you can tweak it slightly before republishing.
Unpublish and discard_draft
discard_draft clears the pending draft (revision still increments) and leaves whatever is currently published untouched. There is nothing to roll back to after a discard: the draft is simply gone.
publish with action: 'unpublish' takes a published page, post, template, or custom entity off the site while keeping everything needed to bring it back: the published content, its slug, and its full version history all remain stored. Calling unpublish on an already-unpublished entity with a fresh expectedRevision is a no-op reported as nothing_to_unpublish, not an error and not a second mutation. To bring an unpublished entity back: if it still has a draft, publish promotes it normally; if it has no draft, use rollback to restore a version into the draft first. Noon never silently synthesizes a draft for you.
Scheduled publishing
publish with action: 'schedule' (entityType, id, runAfterMs, no expectedRevision) queues a publish instead of running one immediately.
runAfterMs is the earliest instant the publish may run, epoch milliseconds. It is not a deadline: a background sweep picks up due work and publishes whatever the entity's draft actually is at the moment the sweep runs, not whatever it was when you scheduled it. If you keep editing the draft after scheduling, the sweep publishes your latest edit. A runAfterMs in the past is accepted and means "as soon as the next sweep runs."
Scheduling the same entity twice replaces the pending schedule rather than adding a second one: an entity has at most one pending scheduled publish at a time. Rescheduling moves the time; it does not reset a schedule that has already failed and retried.
Slugs and permalinks
A slug change on an entity that has ever been published takes effect immediately: the new slug becomes the current address, and the old URL keeps working as an alias. Noon never frees a published path for another entity to claim silently. A slug change on an entity that has never been published just updates the addressing column; there is no alias to create because there is no published URL yet.
Only entities of user-defined content types can be deleted (manage_content_type, action: 'delete-entity'); pages, posts, and templates keep their own lifecycle and are taken off the site with action: 'unpublish' instead. A delete requires expectedRevision and is refused while any of the following are true:
- Another entity's
referencefield, published or in its own draft, still points at it. - A stored version snapshot elsewhere still points at it.
- Another entity still carries it as an assigned term.
- It has ever been published. A permalink is a contract Noon does not break: once an entity has occupied a URL, that entity can never be deleted, even after you unpublish it.
Call action: 'list-references' first to see exactly what is blocking a delete before you attempt it.
Limits
These are enforced constants you will hit as an author, not soft guidance.
| Limit | Value |
|---|---|
| Max content type name / field name length | 64 characters |
| Max content type label / field label length | 512 characters |
| Max fields per content type | 48 |
Max declared length for a string field (maxLength ceiling) | 4096 characters |
Max members in a string field's enum | 64 |
Max length of one enum member | 64 characters |
Max term assignments/unassignments per manage_content_type call | 100 pairs |
Furthest a scheduled publish's runAfterMs may sit ahead of now | 365 days |