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