Docs
Dashboard

Concepts

Rich text

One document, three views, and a node schema that is ours rather than an editor's.

A richtext field stores a document and serves three views of it.

"body": {
  "html":  "<h2 id=\"install\">Install</h2><p>Run <code>npm i</code>…</p>",
  "json":  { "version": 2, "content": [ /* nodes */ ] },
  "plain": "Install Run npm i …",
  "toc":   [ { "level": 2, "text": "Install", "id": "install" } ]
}
  • html: semantic HTML, ready to render. External links carry rel="nofollow noopener". Images carry width/height when known and become <figure><img><figcaption> when captioned. A callout is <aside class="callout callout-info">. A table is a real <table> with <thead> and <th>. An embed is a responsive <iframe> for a known provider and a plain link otherwise. Never a third-party script.
  • json: the stored document, with image URLs and embed URLs resolved and link rel added. Build your own renderer on this when you want React components instead of dangerouslySetInnerHTML.
  • plain: text only. For meta descriptions, previews and search. Image captions and table cells are included; alt text and embed URLs are not.
  • toc: the headings in order, with the same ids the rendered HTML puts on them, so #id lands. Build in-page navigation from this rather than parsing the HTML.

A complete document

Every node and every mark puts its parameters under attrs. There is no flat form. This is the single thing people get wrong about the format, so here is a whole document to copy:

{
  "version": 2,
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 2 },
      "content": [{ "type": "text", "text": "Installing" }]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Run " },
        { "type": "text", "text": "npm install", "marks": [{ "type": "code" }] },
        { "type": "text", "text": ", then read the " },
        {
          "type": "text",
          "text": "guide",
          "marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }]
        },
        { "type": "text", "text": "." }
      ]
    },
    {
      "type": "codeBlock",
      "attrs": { "language": "bash" },
      "content": [{ "type": "text", "text": "npm install" }]
    },
    {
      "type": "bulletList",
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "content": [{ "type": "text", "text": "A list item holds paragraphs." }]
            }
          ]
        }
      ]
    },
    {
      "type": "callout",
      "attrs": { "variant": "warning" },
      "content": [
        {
          "type": "paragraph",
          "content": [{ "type": "text", "text": "A callout holds blocks too." }]
        }
      ]
    },
    {
      "type": "image",
      "attrs": {
        "assetId": "med_2c8RkXqGn4wVtYbMhJpDs",
        "alt": "The dashboard",
        "caption": "Printed under the picture."
      }
    },
    { "type": "horizontalRule" }
  ]
}

The same document is in /v1/introspection under rich_text.example, so an agent has it without reading this page.

The node schema

Current version: 2. Both 1 and 2 are accepted on write.

Block nodes: paragraph, heading, bulletList, orderedList, listItem, blockquote, codeBlock, callout, table, tableRow, tableCell, tableHeader, image, embed, horizontalRule.

Marks: bold, italic, underline, strike, code, highlight, link.

Heading levels: 2, 3 and 4. Level 1 belongs to the page, not to the body of a field.

listItem appears only inside a list, and tableRow/tableCell/tableHeader only inside a table. Every other node may appear at the top level.

Versions

Send "version": 2. A document sent as version 1 is accepted, because schema 2 is a superset of schema 1, and stored as 2. A version above 2 is refused.

A document stored at version 1 keeps saying 1 when you read it, and renders exactly as it always did. Callouts, tables, embeds and image captions are the schema 2 additions.

Images inside a document

An image node takes either assetId or src: exactly one, never both and never neither.

  • assetId is a med_… id from this project’s media library. It resolves to a URL on every read, and on a project with a private bucket gains url_expires_at.
  • src is an absolute http(s) URL to an image hosted elsewhere, stored verbatim. Nothing else is accepted: no data:, no protocol-relative //host, no /path, no #fragment.

Reading one back, it carries "source": "library" or "external" and a url. An external image’s URL is its stored src: it never expires, we never fetch it, and the server it lives on is not ours, so it can move, disappear or block hotlinking. That is the trade-off of pasting a URL in rather than uploading.

alt is for a screen reader; caption is printed under the picture and counts toward the reading time.

Code blocks and syntax highlighting

A codeBlock carries its language under attrs:

{
  "type": "codeBlock",
  "attrs": { "language": "bash" },
  "content": [{ "type": "text", "text": "npm install" }]
}

The renderer turns that into class="language-bash" on the <code> element, which is what Shiki, Prism and highlight.js all read:

<pre><code class="language-bash">npm install</code></pre>

Reading a document back, language is always a string. A block with no language chosen answers "plaintext", never null, so a frontend can switch on the value without deciding whether null meant “unset” or “deliberately not highlighted”, two things that render identically and so are not worth distinguishing.

plaintext is deliberately not emitted as a class: asking a highlighter to highlight plain text does work with no effect.

Pasting Markdown and MDX

Paste a Markdown or MDX file into the article editor and it arrives formatted: headings, lists, quotes, tables, code blocks with their language, links, pictures and horizontal rules become the matching blocks above. The editor tells you when it has done this, so a paste that was meant to be plain text is one undo away.

The editor only reads text as Markdown when it has the shape of a document. A sentence with an asterisk or a bracket in it is pasted as the sentence it is. A paste from a web page or a word processor keeps its own formatting instead, because that is the better source.

An MDX file is read the same way, with three differences:

  • Front matter is read, not pasted. A title, description, tags, slug, date and category names it carries go into the article’s own fields, but only into fields that are still empty. A category has to exist already to be matched. import and export lines are left out.
  • <Callout>, <Note>, <Tip>, <Warning> and similar components become callouts, with their type and title. <Image> becomes a picture and <YouTube>, <Vimeo> and <Tweet> become embeds.
  • Any other component is unwrapped so its words are kept, and the editor names it so you can check the result.

A file with no front matter that opens with a level-one heading gives that heading to the title field, when the title is empty, and the body starts after it. Any other level-one heading becomes a level two, because the article’s title is the page’s own heading. Task-list boxes become the characters for them, since the document format has no checkbox.

Callouts, tables and embeds

Callouts take { "variant": "info" | "warning" | "success" | "danger" } and hold paragraphs and lists. The variant is the callout’s meaning and becomes class="callout callout-…" on the rendered <aside>.

Tables hold tableRow; a row holds tableCell or tableHeader; a cell takes colspan and rowspan (positive integers, default 1). Leading rows made entirely of tableHeader become the <thead>.

Embeds take a { "url" }, held to the same rule as a link href. provider and embedId are derived by the server and any values you send for them are discarded. youtube, vimeo, x and codepen get a responsive iframe; anything else is generic and renders as a link.

Writing a document

Unknown marks are dropped on save. Unknown node types are refused with an error naming what is allowed (unknown_richtext_node), because silently dropping a node loses content somebody wrote.

link marks carry attrs: { "type": "link", "attrs": { "href": "…" } }. rel and target are set by the renderer and never stored.

If you are generating documents programmatically, /v1/introspection returns this whole contract under rich_text: every node type, mark, heading level, callout variant and embed provider, as arrays you can validate against.

Editing a document you already have

To change an existing document, read it, edit the json, and send that back as the field’s value. The read shape is accepted on write. The derived keys an image or embed carries back (source, url, url_expires_at, embedUrl) are ignored on the way in, so there is no need to strip them first.

One thing to know before you build on this. When a document is read, an image node is filled in from the library for anything it does not carry itself:

{ "type": "image", "attrs": { "assetId": "med_…" } }

comes back with alt, width and height taken from the asset. Send that back and those values become the node’s own, so replacing the file in the media library no longer updates the article. It keeps the alt text and the dimensions the asset had on the day you edited it, and stale dimensions mean the wrong aspect ratio on the page.

If you want the library to stay in charge of an image, drop alt, width and height from the node before sending it, leaving assetId. Set them only when this placement genuinely needs its own. The same applies in a smaller way to a code block with no language: it reads back as "plaintext", and sending that back stores plaintext for good. Nothing renders differently either way.

Updated at, Sunday, September 20, 2026