The materialize endpoint turns an existing object — one or more messages, one or more lists, a set of list rows, a document, or a selection of document elements — into a new List, a new Document, or both. It backs the "Create from…" feature in the web app. This is a Subscriber-only endpoint, mirroring the list/document create gates.
The request sends id-only references, never content. The server re-fetches and authorizes every referenced id under the calling user, then derives the new list/document from that authoritative data. Client-supplied cell values or body text are not trusted.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/materialize | Session or Bearer | Create a List, a Document, or both from a source object. Subscriber only. |
Auth required: yes (Session or Bearer) Subscriber only: yes
Request body
{
"target": "list | doc | both | message",
"source": { "kind": "messages", "messageIds": ["clx…"] },
"listConfig": { "…": "optional; see below" },
"docConfig": { "…": "optional; see below" }
}
| Field | Type | Required | Description |
|---|---|---|---|
target | string | yes | list, doc, both, or message. The first three create; message returns a DRAFT (see below). |
source | object | yes | An id-only source reference (see the source kinds below). The server re-fetches and authorizes every id. |
listConfig | object | no | Column/schema configuration when target includes a list. |
docConfig | object | no | Title/path/style configuration when target includes a document. |
messageConfig | object | no | Body/threading configuration when target is message. |
Source kinds (source.kind)
kind | Fields | Materializes |
|---|---|---|
messages | messageIds: string[] | One or more messages. |
lists | listIds: string[] | One or more whole lists (schema + rows). |
rows | listId: string, rowIds: string[] | Selected rows from one list. |
document | documentId: string | A whole document. |
docElements | documentId: string, markdown: string | A selection of a document, identified by the document id plus the selected markdown. |
Every id in a source is ownership-checked; referencing something you do not own returns 404.
listConfig (used when target is list or both)
| Field | Type | Description |
|---|---|---|
title | string | New list title. |
description | string | Optional list description. |
isPublic | boolean | Public visibility of the new list. |
fields | array | Column definitions. Each has propertyKey, propertyName, propertyType, optional isRequired/options, and a sourceKey mapping the column to a source attribute (null for a user-added empty column). The server re-derives values from the source via sourceKey — it does not accept client cell values. |
includeData | boolean | Seed the list with rows derived from the source (default true). Set false to create an empty, columns-only list. |
docConfig (used when target is doc or both)
| Field | Type | Description |
|---|---|---|
title | string | New document title. |
relativePath | string | Optional folder path for the new document. |
isPublic | boolean | Public visibility of the new document. |
listStyle | string | numbered or bulleted — how list/row sources render as a document. |
rowDataStyle | string | inline or sub-items — how each row's fields are laid out. |
Response 201 Created
{
"list": { "id": "clx…", "title": "From my messages" },
"document": { "id": "clx…", "title": "From my messages" }
}
list is present when a list was created, document when a document was created; a both target returns both.
target: "message" — the one that does not createThis target completes the conversion matrix: structured work becomes a post. It returns a draft and writes nothing.
{
"message": {
"content": "Books to Read\n\nMy reading backlog.\n\n• The Dream Machine\n\n340 items in total.",
"thread": ["Books to Read\n\nMy reading backlog.\n\n• The Dream Machine\n\n340 items in total."],
"isThread": false,
"charLimit": 300
}
}
Publish it with POST /api/messages, sending content. That endpoint is where every posting gate lives — account status, verified email, the subscriber checks for media, cross-posting and scheduling, probation rate limits, and your own maxMessageLength. Re-implementing them here would duplicate the most safety-critical write path in the product and let them drift apart, so this endpoint deliberately stops at the draft. A prefill must never bypass a rule the composer enforces.
messageConfig
| Field | Type | Notes |
|---|---|---|
content | string | Your edited body. When omitted, the server derives one from the source. Either way it is re-checked against the limit server-side, so a client cannot hand back a body longer than the account allows. |
crossPostTargets | string[] | Channel labels (Bluesky, Mastodon, LinkedIn, X/Twitter). Used to size the draft. |
allowThread | boolean | Default false. When the body exceeds the limit the request is rejected unless this is true, in which case thread carries the split parts in reply order. Splitting someone's post without asking is not a decision the server makes silently. |
publiclyVisible, tags, scheduledAt | — | Accepted and passed through to your composer call; enforcement happens at POST /api/messages. |
charLimit is the smaller of your maxMessageLength (from GET /api/user) and the tightest limit among crossPostTargets. Sizing to your own limit alone would show one post and then silently send a three-part thread to Bluesky.
A messages source is accepted (several messages combine into one draft), but message → message is not offered in the UI — use Quote or Push for a single message.
Error responses
| Status | Condition |
|---|---|
| 401 | Not authenticated. |
| 403 | Authenticated but not a subscriber. |
| 400 | Missing source, or an invalid request (bad field config, unsupported source shape). |
| 404 | A referenced id (message, list, row, or document) is not found or not owned by you. |
| 500 | Unexpected server error. |