Document types
What a document type is
Every document in Oddlynew is created against a document type. The type is the contract: it names the block types a document may contain, states which of them are allowed at the document root and in what quantity, optionally declares JSON Schemas for the document's settings and metadata, and optionally declares which metadata fields become filterable in search and listing calls.
Types are flat and curated. There is no inheritance, no base type, no mixin, and no partial override. A type either declares a block or it does not. When you need a variation, you write a whole type (usually as a revision of an existing one, see the last section) rather than extending one.
Two kinds of type are visible to you: platform types owned by Oddlynew, which every organization can read and use, and types owned by one of your organizations. Platform types have organizationId: null in every response.
Everything below runs through the documents namespace inside run. Reads need the mcp:read scope, and createType additionally needs mcp:write plus create permission in the target organization.
Finding a type that already exists
listTypes browses, searchTypes matches text against name, description, and tags.
const organizationId = "0199bf3a-77c1-7c02-9d11-2233445566aa";
const listed = await documents.listTypes({ organizationId, limit: 50 });
const found = await documents.searchTypes({ organizationId, query: "meeting", limit: 20 });
return {
all: listed.types.map(t => ({ id: t.id, name: t.name, org: t.organizationId })),
matches: found.types.map(t => t.name),
hasMore: listed.hasMore,
};Both require organizationId and accept tags, cursor, and limit; searchTypes also requires a non-empty query. A call that omits organizationId fails parameter validation before it runs. limit is an integer between 1 and 50 and defaults to 20. Paginate by passing nextCursor back as cursor until hasMore is false.
Search before you create a type or a document, and before you ask a person whether a kind of record exists. Pass the plain word for the kind as query, such as debt, idea, decision or customer, and read the description of the top rows: it says what one document of that type holds and when to use it. A question about a type the organization already keeps costs the person a turn for nothing.
What a call returns is worth knowing before you conclude a type is missing: it returns platform types plus that organization's types. There is no single call that spans every organization you belong to, so browse one organization at a time; organizations.listMine lists the ones you can act in.
Each row carries id, name, description, tags, organizationId, creatorId, sourceTypeId, archivedAt, and familyLatestTypeId. That last field is how you detect that a type you are pinned to has been superseded: if familyLatestTypeId differs from id, a newer generation exists.
Neither call returns archived types. There is no flag on the run surface to include them.
Reading one type in full
Inside run, documents.getType requires both the exact typeId and an organizationId discovered for the user's task, including when the type is platform-owned. The organization is the capacity and authorization target; for platform types, it also selects the diagnostic organization. searchAttributeDiagnosticsOrganizationId reports the authorized organization actually counted. It does not change the type definition. Omitting it fails parameter validation before the read. The read mints its own operation ID when omitted; pass an echoed operationId only for an exact retry after an ambiguous result. The UUIDs below are illustrative: substitute the actual discovered organization and type.
// Use an organization and type discovered for the user's task.
const organizationId = "0199bf3a-77c1-7c02-9d11-2233445566aa";
const type = await documents.getType({
typeId: "0199c0d1-2b3e-7a41-9f00-1122334455aa",
organizationId,
});
return {
root: type.allowedBlockTypes,
rules: type.rootRules,
filterable: type.searchAttributes,
revisions: type.familyRevisions.length,
};The response includes allowedBlockTypes, rootRules, schemas (one descriptor per block type), markdocAuthoringGuide (concrete syntax you should read before writing Markdoc), settingsSchema, metadataSchema, searchAttributes, minimalDraft, and familyRevisions (up to 50 generations of the same lineage, newest first, archived ones included).
getType will read an archived type (its archivedAt is populated). A type id that does not exist and a type id you are not allowed to see both fail identically with Document type not found, so a failure here is not proof of absence.
Creating a type
createType takes these fields plus an optional operationId; definition also accepts references, metadataMediaFields, and sizeAdvisoryCharThreshold, and individual block definitions may additionally declare mediaFields and unindexedFields:
organizationId(required UUID): the organization that will own the typename(required, non-empty string): the type's display namedescription(optional string)tags(optional string array)chunkSizeWords(optional integer, 1 or greater): target size in words for the search chunks built from this type's documentssourceTypeId(optional UUID): the type this one revises or forksdefinition(required object)
definition is a serialized document type with its own required name and description, a required blocks array (which may be []), a required content array of root rules, and the optional annotations, settingsSchema, metadataSchema, and searchAttributes. Note the duplication: the top-level name is the catalog name; definition.name is the name carried inside the stored definition. Both are required and they are independent.
The smallest useful type is a freeform prose document:
const type = await documents.createType({
organizationId: "0199bf3a-77c1-7c02-9d11-2233445566aa",
name: "Note",
description: "Free-form note",
tags: ["notes"],
definition: {
name: "Note",
description: "Free-form note",
blocks: [],
content: [{ types: ["markdown"], min: 0, max: null }],
},
});
return { id: type.id, name: type.name };createType returns { id, name, description, tags, sourceTypeId }.
Parameter names are validated strictly. A key that is not part of the schema, at any depth, fails the call with an error naming the offending key and its dotted path. There is no silent drop, so a typo like definition.metaDataSchema fails loudly (Unknown parameter "definition.metaDataSchema" for createType) rather than producing a type with no metadata schema.
Root content rules and the sentinel vocabulary
definition.content must be a non-empty array. Each rule is { types, min, max } and all three keys are required, with max: null meaning unbounded. min must be an integer of 0 or greater, max must be null or an integer of 0 or greater, and max must not be smaller than min. A rule like { min: 10, max: 2 } is rejected up front rather than minting a type under which no document could ever be created.
For prose at the root, use the sentinel "markdown". It is a definition-language token, not a block: it stands for the whole standard Markdown vocabulary (Portable Text text, tables, code, images, dividers, file attachments, audio, HTML files, and external media embeds such as YouTube, Vimeo, Loom, Figma, and X) and is resolved by reference every time the type is loaded, so a type written with it keeps tracking that vocabulary as it grows. "markdown" never appears as a _type in document content.
To narrow the root below standard Markdown, name explicit block types instead of the sentinel. Any type named in a root rule must either be declared in definition.blocks or be one of the names the sentinel supplies (table, code, image, divider, attachment, audio, html, embed, images, video).
The names getType echoes back are not createType input
This is the sharpest edge in the whole surface. getType reports resolved names, not the names you wrote. A type whose root rule is types: ["markdown"] comes back as:
{
"allowedBlockTypes": ["block", "table", "code", "image", "divider", "attachment", "audio", "html", "embed", "images", "video"],
"rootRules": [{ "types": ["block", "table", "code", "image", "divider", "attachment", "audio", "html", "embed", "images", "video"], "min": 0, "max": null }]
}Feeding that back into createType is not a round trip.
"block" in a root rule is rejected outright:
definition.content[0].types uses "block" at the document root; root prose uses the "markdown" sentinel — prose always carries the full default standard vocabulary (text, tables, code, images, dividers, attachments, audio, HTML files, and external embeds). "block" is only valid inside a children spec."block" is the Portable Text text block itself and belongs in a children spec. The other ten (table, code, image, divider, attachment, audio, html, embed, images, video) are accepted at the root, but writing them out freezes your type at today's Markdown vocabulary instead of tracking it. If you meant prose, write "markdown".
The sentinel is root-only. Using "markdown" inside a children spec fails during definition rebuilding, so the message arrives with the Invalid type definition: prefix:
Invalid type definition: Serialized document type uses "markdown" in a children spec; it is a root-rule sentinel. Name the block types the child may contain, or "block" for Portable Text text.Declaring blocks
Each entry in definition.blocks needs type, description, schema (a JSON Schema object), preview ({ templates: [...] }, the first satisfiable template wins), and children. chunk is optional and is one of "prose", "heading", "standalone", "enumeration", "decoration".
children is one of { kind: "none" }, a text spec ({ kind: "text", allowedStyles?, allowedLists?, allowedDecorators?, allowedAnnotations?, maxBlocks? }), or a block spec ({ kind: "blocks", types, min, max }). Block children carry the same cardinality rules as root rules.
const type = await documents.createType({
organizationId: "0199bf3a-77c1-7c02-9d11-2233445566aa",
name: "Meeting note",
definition: {
name: "Meeting note",
description: "Decisions and follow-ups from one meeting",
blocks: [
{
type: "decision",
description: "One decision taken in the meeting",
schema: {
type: "object",
properties: { owner: { type: "string" }, dueDate: { type: "string" } },
required: ["owner"],
additionalProperties: false,
},
preview: { templates: ["{owner}: {dueDate}", "{owner}"] },
children: { kind: "text", allowedAnnotations: ["link"] },
},
],
content: [
{ types: ["markdown"], min: 0, max: null },
{ types: ["decision"], min: 0, max: null },
],
},
});
return type.id;Reserved names cannot be redefined. "block" and "markdown" are never declarable in blocks. The standard names code, image, divider, table, attachment, audio, html, callout, embed, collapsibleSection, todoItem, images, and video are reserved for their canonical schemas: a block using one of those names whose schema, children, or chunk role differs from the canonical one is rejected with Block type "table" is reserved for its standard @oddlynew/block-registry schema; use a different type name for a custom block.
Choosing metadata, blocks and publishing fields
The schema is the contract for both metadata and body blocks. Metadata is not an unvalidated escape hatch, and it is not inherently private or invisible. A field can be reader-facing whether it lives in metadata or in a block. Choose its owner by meaning, scope and reuse, not by where it appears on one screen.
Use document metadata for named facts about the whole item that have a stable shape and are useful independently of reading its body. Examples include recipe servings and duration, an event date and location, or an article summary. Define their value shape in metadataSchema. A field does not need to be filterable to belong here; declare searchAttributes separately only when supported queries need them.
Use body blocks for ordered, repeated or contextual content: recipe ingredients and steps, lesson sections, article prose, or instructions with accompanying media. Put a fact on the smallest block it describes. A cue for judging one baking step belongs on that step; it should not become a second recipe-wide instruction just to place it near the header.
Keep publishing concerns in the Content surface: route, publication state, visibility, authors, tags, SEO and feature image. Document metadata and the publishing API's metadata are different owners. Neither is a place for secrets or internal review notes that must not reach readers. See Publishing and Content for that boundary.
Give each fact one authoritative home and let views reuse it. A card and a header can read the same duration field; do not maintain separate copies of that number in prose. A short preparation summary may be metadata, while the executable instructions remain in their steps. Define units, scope and unknown-value behavior: for example, total duration includes required resting, and an unknown duration is absent rather than zero.
Keep labels and layout in the renderer, and semantics in the type. Render declared kinds and fields rather than guessing from a heading such as Method. Optional fields should not produce empty panels. In authoring guidance, include a short example, a counterexample and a description of each field's purpose; validation alone cannot stop an author from putting a paragraph of editorial caveats into a servings string.
For relationships to independent documents, use stable document IDs and the type's supported reference declarations; do not store display titles as identity or duplicate a target document's body. Keep the member IDs in their declared field and query only through supported API contracts. A reference declaration does not grant access, and adding a metadata field does not automatically make it searchable or available as a Content API filter.
Schema revision, content migration and publication are separate steps. Review representative documents after reclassification, including omitted fields and grouped content, and verify the published view as well as the draft. Creating a newer type does not rewrite old documents or republish them.
Metadata
definition.metadataSchema is a JSON Schema object validated against a document's metadata on every write. Three consequences are worth stating plainly.
A type without metadataSchema rejects all metadata. Creating or editing a document of such a type with a non-empty metadata object fails with This document type does not define metadata. Passing null or {} is fine.
additionalProperties: false is enforced, not advisory. Any metadata key outside properties is rejected on write. This is usually what you want, but it means you must add a property before documents can carry it, and it means a field you declare in searchAttributes but forget to add to properties can never receive a value. Omitting additionalProperties leaves the object open and undeclared keys pass through.
Nullable fields work, format: "date-time" has a catch. Both { "type": ["string", "null"] } and { "anyOf": [{ "type": "string" }, { "type": "null" }] } validate as expected. Formats such as uuid, uri, email, and date are safe. But a bare { "type": "string", "format": "date-time" } accepts only Z-suffixed UTC timestamps: "2024-01-01T00:00:00Z" passes, "2024-01-01T00:00:00+02:00" is rejected. If your writers emit offsets, keep the metadata property a plain string and rely on the datetime search attribute for querying, which does accept offsets.
Making metadata filterable
definition.searchAttributes is a sibling of metadataSchema, not a marker inside it. Each entry is { field, kind } where field is a top-level key of the document's metadata and kind is one of "text", "number", "datetime", "boolean".
return await documents.createType({
organizationId: "0199bf3a-77c1-7c02-9d11-2233445566aa",
name: "Imported note",
definition: {
name: "Imported note",
description: "A note imported from an external vault",
blocks: [],
content: [{ types: ["markdown"], min: 0, max: null }],
metadataSchema: {
type: "object",
properties: {
collection: { type: "string" },
sourcePath: { type: "string" },
importedAt: { type: "string" },
wordCount: { type: "number" },
pinned: { type: "boolean" },
},
additionalProperties: false,
},
searchAttributes: [
{ field: "collection", kind: "text" },
{ field: "sourcePath", kind: "text" },
{ field: "importedAt", kind: "datetime" },
{ field: "wordCount", kind: "number" },
{ field: "pinned", kind: "boolean" },
],
},
});Declared fields become filters on listDocuments and search via metadataFilters, and getType reports the legal operators per field in searchAttributes[].ops: text takes eq/in/prefix, number and datetime take eq/in/range, boolean takes eq. Text fields can additionally be enumerated with listMetadataValues.
The hard rules:
At most 10 attributes per type. An eleventh fails with
Invalid type definition: SerializedDocumentType.searchAttributes declares 11 attributes; at most 10 are allowed.Field names are capped at 250 characters:
Invalid type definition: searchAttributes field names are limited to 250 characters.A field named twice fails with
Invalid type definition: searchAttributes declares the field "collection" more than once.There is no
enumkind, deliberately. Declaring a field queryable never constrains its values. An unrecognized kind does not even reach that check:kindaccepts only the four literals at parameter validation, sokind: "enum"fails as an invalid parameter with the tool's signature attached.
Coercion failures are silent by design. A value that does not fit the declared kind is simply not projected, so a filter will not find that document, and no write error is raised. text requires a non-empty string of at most 1000 bytes. number requires a finite JSON number. boolean requires a real boolean. datetime requires YYYY-MM-DD or YYYY-MM-DDTHH:MM with optional seconds and a required Z or ±HH:MM offset; a date-only value denotes the whole UTC day. getType called with include: ["unprojectedCounts"] surfaces the damage as searchAttributes[].unprojectedCount: a persistently non-zero count means your declaration and what your documents actually write disagree.
Prefer an open text attribute plus listMetadataValues for anything whose value set grows (collections, tags, batch ids). Reserve a metadataSchema enum for a genuinely closed set such as a status workflow, because an enum needs a new type generation the day a third value appears.
When a kind gets its own type
Give a kind of thing its own type when one of two signs appears, and not before.
Something reads it by kind. A Loop's definition names it as what its cycles start or end on. Someone will list or search only that kind, with
typeIdsonlistDocumentsorsearch. A filter needs one of its fields, and only a type can declare a filterable field. A site or app lists or renders documents of that type. A person says they will keep, list or look up many of them, such as customers, orders or cases, or the organization's AGENTS.md says "one document per case".It repeats with the same fields. You are about to write the second document of one kind, and both carry the same facts, such as a status, a date or an owner. A second title that would start with the same kind, such as "Case: ...", is the sign.
Until then, write the document in plain Markdown or in a type that already fits it, and give it a clear title that names the thing. A one-off document, such as a business plan or a house style, does not need a type of its own, and neither does a kind nobody can name yet.
Count within one organization. A type belongs to the organization that keeps those documents, as Work across several organizations describes. An agency that keeps one brief in each client's organization has a one-off in each of them, not a repeating kind.
When a sign appears, first search the organization's types for the kind, as Finding a type that already exists describes, and use or revise the type that already holds it. If none does, create an organization-owned type with an honest name in the organization's own words and an open Markdown body: a type is a table and each document is a row. When the person named the kind, that is their yes. When you inferred it, propose it to them in one line first, and keep the clear title until they agree. Move the documents of that kind you already wrote to the new type with documents.reclassifyDocument, so a listing by type finds all of them, and keep each title for the thing's own name, not its kind. The type's description says what one document holds and when to create one. It also says that this version names the kind without promising an internal structure, so the type id becomes a durable index without guessed fields or blocks.
Naming a kind and constraining its shape are separate steps. The signs above decide when a kind earns a type. Its blocks and required fields wait until the documents show a stable shape, as described below; declare a filterable field when a filter needs it.
If the new organization-owned type names a platform type as sourceTypeId, it starts a new family because ownership differs; sourceTypeId remains provenance for the fork. Later revisions created by the same organization remain in that custom family.
Start with the fields the person named, none of them required, and tighten the type as the documents show a recurring shape: add structured blocks, required fields and more filterable search attributes when real authoring or consuming applications prove them useful. Keep contextual prose in body blocks and stable document-level facts in metadata. Query declarations are separate from the decision to structure a fact.
Creating a type or revision does not move existing documents. A document stays pinned until an explicit reclassifyDocument call; if the new category changes what the document means, create a replacement instead.
Example: ordinary Markdown → Product Principle with an open Markdown body → a later Product Principle revision whose structure and metadata were learned from the accumulated examples.
One concept, one type, for the whole organization
Types are the shared map of an organization, not one person's or one agent's vocabulary, so concepts are decided at organization level. Before you create a type, a field or a set of status values, search the organization's types as Finding a type that already exists describes, and reuse or revise the one that already holds the concept, whoever created it.
Two types for one thing split the map: agents that find one of them miss half the records, and rules that read one type silently skip the other. When you find competing types, merge them by a new revision of the one you keep, and keep the old name as an alias in its description or tags so searches for the old word still land on it. Stored documents keep their ids and links; reclassify them or let them stay done as Migration and cut off are both first class describes.
Types are portable application contracts
A structured type is useful beyond validation. It gives agents stable semantic coordinates — a known block type, known fields, cardinality, and independently addressable records — so they do not have to reverse-engineer structure from prose on every read.
The same structure is also a rendering contract. Oddlynew itself must be able to display organization-defined types it has never seen before, so the app provides a generic renderer for unknown custom blocks. A product-specific site or application can instead consume the typed document through the Documents or Content API and map those same semantic blocks to its own components: a repeated capability block can become a table, a status block can become a badge, and a structured recipe can become a purpose-built recipe page.
Treat the document as canonical semantics and the renderer as a projection. Do not weaken a useful schema merely to make the generic Oddlynew view resemble one bespoke frontend. Conversely, do not add structure only for presentation: tighten a type when the semantic shape itself is stable and useful to agents or downstream consumers.
Revising a type
There is no update or edit operation for types. A revision is a createType call carrying sourceTypeId:
const organizationId = "0199bf3a-77c1-7c02-9d11-2233445566aa";
const current = await documents.getType({
typeId: "0199c0d1-2b3e-7a41-9f00-1122334455aa",
organizationId,
});
const next = await documents.createType({
organizationId,
name: current.name,
description: current.description,
tags: current.tags,
sourceTypeId: current.id,
definition: {
name: current.name,
description: current.description,
blocks: [],
content: [{ types: ["markdown"], min: 0, max: null }],
...(current.metadataSchema ? { metadataSchema: current.metadataSchema } : {}),
searchAttributes: current.searchAttributes.map(a => ({ field: a.field, kind: a.kind })),
},
});
return { previous: current.id, next: next.id };getType echoes metadataSchema exactly as it was stored, or null when the type declares none, so copying it forward is safe. Spreading a null one into {} is not: an empty JSON Schema accepts any metadata, while no schema at all rejects all of it.
The result is a new type with a new id in the same lineage. Lineage is system-managed: creating the revision in the organization that already owns the source keeps the lineage; passing a sourceTypeId owned elsewhere (for example, a platform type) forks into a fresh lineage instead, with sourceTypeId retained as provenance.
To reclassify one compatible document, read its current writeBasis and typeId, inspect the target with getType, then call documents.reclassifyDocument({ documentId, writeBasis, expectedTypeId, targetTypeId }). Oddlynew validates the live content, settings, and metadata; writes one new immutable version under the target type; preserves the document ID, links, and comments; and leaves historical versions under their original types. Optional metadata is a complete replacement. After an ambiguous failure, reread before retrying.
Because getType echoes resolved names rather than the definition you submitted, do not build a revision by copying allowedBlockTypes or rootRules back into content. Keep the definition you authored, edit it, and resubmit it.
Migration and cut off are both first class
A revision defines the shape going forward; it does not obligate the past. Reclassify when the same document must participate in the target type's validation or queries. Cut off when the old corpus is finished. If the target would fundamentally reinterpret the content, archive the old document and create a new one. Do not migrate solely for uniformity.
The instinct that every document must match the current schema comes from databases, where the schema is the only reader. Here the reader is an agent. It can read the exact generation a document is pinned to, see that the shape changed, and handle both without help. So prefer the lighter strategy that serves the future corpus, and do not run a migration whose only product is uniformity. Renaming a type, tightening its metadata, or splitting one genre into two are all cheap when the old documents are simply allowed to stay done.
Retiring a type
Use documents.archiveType when an organization-owned revision is superseded or must no longer accept new documents.
return await documents.archiveType({
organizationId: "0199bf3a-77c1-7c02-9d11-2233445566aa",
typeId: "0199c0d1-2b3e-7a41-9f00-1122334455aa",
});Archiving a type is retirement, not deletion. You can archive it while documents still use it. Existing documents keep their exact typeId and remain readable, editable, searchable, and citable. listTypes and searchTypes stop returning the revision, and createDocument rejects it for new documents. Exact getType({ typeId, organizationId }) reads it with archivedAt, and familyRevisions keeps it in the lineage. The call does not migrate documents.
Only organization-owned types can be archived. Platform types cannot. The call requires document-management permission and is idempotent, so a retry preserves the original archivedAt. The current surface has no restoreType, so identify the exact revision before archiving it. When replacing a type, create the successor with sourceTypeId first, then archive the superseded revision.
Errors at a glance
createType rejects before writing anything, so a failed call never leaves a partial type. Two layers reject, and they read differently.
Parameter validation runs first, against the tool's own signature. An unknown key at any depth, a missing required key, or a value of the wrong shape (a blocks that is not an array, a kind outside the four literals) fails there, with messages like Unknown parameter "definition.metaDataSchema" for createType or Missing required parameter "definition" for createType, each followed by the full Signature: createType(input: { ... }).
Definition checks run second, on a structurally valid payload:
definition.content must be a non-empty array of root content rules; see the createType description for a minimal example.definition.content[0].min must be an integer >= 0, ordefinition.content[0].max must be null or an integer >= 0definition.content[0].min must be <= max (10 > 2 can never be satisfied)definition.content[0].types references undeclared block type "summary"; declare it in definition.blocks or use the "markdown" sentinel (text, tables, code, images, dividers, attachments, audio, HTML files, and external embeds).
Source type not foundwhensourceTypeIddoes not resolve or is not visible to you.Invalid type definition: ...for anything caught while rebuilding the runtime from your definition, including the children-spec sentinel message and everysearchAttributesviolation.
Search-attribute limits
A type can declare up to 10 search attributes. Across an organization, attributes are pooled against a search-index budget of 1,024 slots — far more than any realistic set of types uses, but the pool has three rules worth knowing. The same field name with the same kind, declared by several types, shares one slot. Changing a field's kind (say, text to datetime) consumes a new slot rather than converting the old one. And a field you stop declaring frees its ongoing indexing cost, but its slot stays spent — slots are never reclaimed in place. In practice: reuse field names and kinds deliberately across types, and settle a field's kind before declaring it widely.