# 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](/schema/deploying)). The dashboard displays schemas but never edits them — the files in your repository are the source of truth. ```ts 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` | 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: ```ts 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 ```ts 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](/schema/deploying). ## 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](/schema/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`: ```ts 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=`](/content-api/reading-content) 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 ```bash $ 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` | 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: ```ts 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.() })` 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](/schema/deploying) — diff, push, and the destructive-change model. - [Generated TypeScript types](/schema/generated-types) — turn schemas into interfaces.