Schema
Defining schemas
Author collections in TypeScript with the code-owned schema DSL.
Myna schemas are code-owned. You define collections in TypeScript files with the DSL exported from @myna-sh/sdk/schema, and deploy them with the CLI (see deploying schema changes). The dashboard displays schemas but never edits them — the files in your repository are the source of truth.
import { collection, field, item } from "@myna-sh/sdk/schema";
collection()
collection(input) returns the canonical schema for one collection. Fields are declared as an object map; key order is preserved and becomes the canonical field order.
| Option | Type | Default | Notes |
|---|---|---|---|
name |
string |
required | Immutable collection key, e.g. posts. Must be lowercase alphanumeric/underscore and start with a letter. Renaming is a remove + add. |
label |
string |
name |
Display name. Editable at any time. |
kind |
"collection" | "singleton" |
"collection" |
Singletons hold exactly one item and cannot define a slug field. Changing kind later is destructive. |
visibility |
"public" | "private" |
"private" |
Must be explicitly declared public for unauthenticated reads. |
titleField |
string |
null |
Key of a top-level field used as the display title. Must reference an existing field. |
path |
string |
null |
Path template such as /blog/{slug}. |
guidance |
string |
— | House style for this collection: voice, length, conventions, what belongs here. |
fields |
Record<string, FieldInput> |
required | Map of field key to field.*() definition. |
There is no slugConfig option. Slug configuration is derived automatically from a field.slug() in fields: the collection's slug config takes from from the slug field's from option, falling back to titleField, then to the slug field's own key; required comes from the slug field's required.
Slugs are opt-in. A collection that declares neither a field.slug() nor a path gets none: its entries are addressed by id, an entry's slug is null, and the dashboard editor shows no slug control. No field is required to create an entry unless your schema says so.
Guidance
A field's description says what a field is for. guidance says what good looks like — the thing a new contributor is told once and then forgets, and the thing an agent has no way to infer from types:
export const posts = collection({
name: "posts",
guidance: [
"Write in second person, present tense. No exclamation marks.",
"Titles are sentence case and under 60 characters.",
"Open with the reader's problem, not with Myna.",
].join(" "),
fields: { /* … */ },
});
Because it lives in the schema, guidance is versioned, reviewed, and deployed like the rest of it. It is returned by GET /v1/projects/:project/collections/:collection, shown in the dashboard's schema view, and included in what the myna_get_collection_schema MCP tool hands an agent before it writes anything.
PATCH /v1/projects/:project carries a project-level guidance for what applies regardless of collection. That one is a project setting rather than schema, because it is about the project's voice, not any collection's shape.
Worked example
import { collection, field, item } from "@myna-sh/sdk/schema";
export const posts = collection({
name: "posts",
label: "Blog posts",
visibility: "public",
titleField: "title",
path: "/blog/{slug}",
guidance: "Second person, present tense. Sentence-case titles under 60 characters.",
fields: {
title: field.text({ required: true, maxLength: 120 }),
slug: field.slug({ from: "title", required: true }),
status: field.text({ enum: ["draft", "review", "live"], default: "draft" }),
body: field.markdown({ required: true }),
wordCount: field.number({ integer: true, min: 0 }),
featured: field.boolean({ default: false }),
publishedAt: field.datetime(),
cover: field.asset({ allowed: ["image/*"] }),
author: field.reference({ to: "authors" }),
tags: field.list({ of: item.text({ maxLength: 32 }), maxItems: 10 }),
seo: field.object({
fields: {
metaTitle: field.text({ maxLength: 60 }),
metaDescription: field.text({ multiline: true, maxLength: 160 }),
},
}),
extra: field.json(),
},
});
Each file in your schema directory may default-export a collection, export an array of collections, or export several named collections — see how schema directories are loaded.
Common field options
Every field.*() builder accepts these options in addition to its own:
| Option | Type | Default | Notes |
|---|---|---|---|
label |
string |
— | Display label. |
description |
string |
— | Emitted as a doc comment in generated types. |
required |
boolean |
false |
Whether the field must be present on every entry. |
localized |
boolean |
false |
Store one value per project locale as { [locale]: value }. Only top-level, non-slug fields. See below. |
ui |
FieldUi |
— | Non-semantic UI metadata: widget, placeholder, helpText, group, hidden. Never affects validation. |
Field keys must start with a letter and be alphanumeric/underscore. Keys are immutable identity: changing a key diffs as removing the old field and adding a new one. Labels, descriptions, ui metadata, and defaults are cosmetic and can be edited freely — such changes always classify as safe.
Localization
Localization is structural and typed, not an arbitrary JSON convention. Enable it per project by setting locales (e.g. ["en", "pt-BR"]) and a defaultLocale via PATCH /v1/projects/:project or Project settings → General. Then mark fields with localized: true:
title: field.text({ required: true, localized: true }),
body: field.markdown({ localized: true }),
A localized field stores { [locale]: value } — e.g. { "en": "Hello", "pt-BR": "Olá" } — and each locale's value is validated against the field's own rules. A required localized field must at least carry the default locale; other locales may be missing and fall back at read time. Translations are ordinary entry edits, so they stage on the same change set as the source content and publish atomically with it. If the project has no locales configured, localized fields validate as plain values.
Generated TypeScript types render localized fields as { [locale: string]: T }. On the public content API, pass ?locale= to resolve localized fields to a single value with default-locale fallback. Localized fields are filterable and sortable: comparisons resolve to the requested locale, falling back to the default locale the same way reads do.
Finding what is not translated
$ myna schema translations posts
25 entr(y/ies), localized fields: title, body, excerpt
LOCALE COMPLETE PERCENT MISSING FIELDS
en 25/25 100% 0
pt-BR 18/25 76% 11
GET /v1/projects/:project/collections/:collection/translations reports, per locale, how many entries are complete, what share of (entries × localized fields) is filled in, which fields are missing most often, and a bounded worklist of entries to fix. It reads each entry's working head — the draft when there is one — so a translation in progress counts. The myna_translation_coverage MCP tool returns the same report.
Field type reference
field.text(options?)
| Option | Type | Default |
|---|---|---|
multiline |
boolean |
false |
minLength |
number |
— |
maxLength |
number |
— |
pattern |
string (regex) |
— |
enum |
string[] |
— |
default |
string |
— |
With enum set, values are restricted to the listed strings and the field generates a string-literal union type. A default must be one of the enum values.
field.number(options?)
| Option | Type | Default |
|---|---|---|
integer |
boolean |
false |
min |
number |
— |
max |
number |
— |
default |
number |
— |
field.boolean(options?)
| Option | Type | Default |
|---|---|---|
default |
boolean |
— |
field.date(options?) / field.datetime(options?)
Both take the same options. date values are ISO dates (2026-07-23); datetime values are ISO datetimes with an offset.
| Option | Type | Default |
|---|---|---|
min |
string |
— |
max |
string |
— |
default |
string |
— |
field.markdown(options?)
| Option | Type | Default |
|---|---|---|
minLength |
number |
— |
maxLength |
number |
— |
default |
string |
— |
field.json(options?)
| Option | Type | Default |
|---|---|---|
default |
JsonValue |
— |
Accepts any JSON value; no structural validation is applied.
field.asset(options?)
| Option | Type | Default |
|---|---|---|
allowed |
string[] (MIME families, e.g. image/*, application/pdf) |
["*/*"] |
multiple |
boolean |
false |
Stores asset ids (ast_…) — an array of them when multiple is set. allowed must contain at least one MIME family.
field.reference(options)
| Option | Type | Default |
|---|---|---|
to |
string (target collection key) |
required |
multiple |
boolean |
false |
Stores entry ids (ent_…) — an array when multiple is set. The target collection must exist in the same schema set; validation checks cross-collection references at push time.
field.slug(options?)
| Option | Type | Default |
|---|---|---|
from |
string (source field key) |
falls back to titleField, then the slug field's own key |
Values must match ^[a-z0-9]+(?:-[a-z0-9]+)*$. At most one slug field is meaningful per collection, and singletons cannot have one. Declaring one is what makes the collection's entries URL-addressable — omit it, and they are addressed by id.
field.list(options)
| Option | Type | Default |
|---|---|---|
of |
ListItem (an item.*() builder) |
required |
minItems |
number |
— |
maxItems |
number |
— |
field.object(options)
| Option | Type | Default |
|---|---|---|
fields |
Record<string, FieldInput> |
required |
Nested fields use the same field.*() builders and the same key rules.
field.richText(options?)
Semantic rich text stored as a portable JSON document tree, never rendered HTML. The stored value is { "type": "doc", "content": [{ "type": "paragraph", ... }] }; nodes may carry links, inline entry references (ent_ ids), and embedded assets (ast_ ids). Referenced entries and assets participate in publish-time reference verification like any other field. Generated types render it as RichTextDoc.
field.blocks(options)
Heterogeneous, schema-defined components — hero, quote, gallery, CTA, code example — composed per entry:
import { field, block } from "@myna-sh/sdk/schema";
const Hero = block("hero", {
fields: { heading: field.text({ required: true }), image: field.asset() },
});
const Callout = block("callout", {
fields: { tone: field.text({ enum: ["info", "warning"] }), body: field.markdown() },
});
body: field.blocks({ allowed: [Hero, Callout] }),
| Option | Type | Notes |
|---|---|---|
allowed |
BlockDef[] |
required — block components built with block(key, { label?, fields }) |
minItems / maxItems |
number |
bounds on the block list |
The stored representation is portable JSON: [{ "type": "hero", "fields": { ... } }, ...]. Each block's fields validate against that block's schema (unknown block types and unknown fields are rejected), and generated TypeScript renders a discriminated union over the allowed block keys. Block fields use the same builders and nesting-depth rules as field.object.
List item builders
field.list({ of: item.<type>() }) uses a reduced option set — list items carry constraints but no label, description, required, or default:
| Builder | Options |
|---|---|
item.text(o?) |
minLength, maxLength, pattern, enum |
item.number(o?) |
integer, min, max |
item.boolean() |
— |
item.date(o?) / item.datetime(o?) |
min, max |
item.markdown(o?) |
minLength, maxLength |
item.json() |
— |
item.reference(o) |
to (required) |
item.asset(o?) |
allowed (default ["*/*"]) |
item.object(o) |
fields (required) |
There is no item.slug and no item.list — lists cannot nest directly inside lists; use item.object with a nested field.list instead.
Nesting depth
Object and list-of-object nesting is limited to a maximum depth of 4 (MAX_NESTING_DEPTH). Top-level fields sit at depth 1; each field.object or field.list({ of: item.object(...) }) level adds one. Schemas that exceed the limit are rejected at validation with a too_deep error.
Next
- Deploying schema changes — diff, push, and the destructive-change model.
- Generated TypeScript types — turn schemas into interfaces.
