Skip to content

Author a schema for your metadata standard

Your metadata standard already exists, even if it only lives in a style guide or in people’s heads. Every page needs a type. A timestamp should be an ISO date. API pages must declare a version. A JSON Schema turns those agreements into a file that manni meta can enforce on every commit. This page walks through writing that schema, from a minimal one-field example up to the fuller built-in OKF schema. It also shows how a document points itself at the schema you wrote.

manni meta validates against standard JSON Schema. If you can write JSON, you can write a manni meta schema; there is no manni-specific schema format to learn. A schema describes the frontmatter object: which keys are required, what type each value should be, and whether unexpected keys are allowed.

Here is the smallest schema worth shipping. It says: the metadata must be an object, it must have a title, and that title must be a non-empty string.

title.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["title"],
"properties": {
"title": { "type": "string", "minLength": 1 }
}
}

This is the extra.schema.json fixture that manni meta’s own test suite runs, so you can trust it compiles and validates exactly as shown. Four things are doing the work:

  • "$schema" at the top names the JSON Schema dialect this file is written in, which here is draft 2020-12. The line tells manni meta which validation engine to use. This is the schema’s own dialect declaration. It differs from the $schema key you put in a document to choose a schema, covered below.
  • "type": "object" says the metadata is a set of key/value pairs, which frontmatter always is.
  • "required": ["title"] is the list of fields that must be present. A document missing title fails validation.
  • "properties" describes individual fields. title must be a string, and minLength: 1 rejects an empty title: that a contributor left blank.

A document with this frontmatter passes:

---
title: Getting started
---

A document with empty or missing title fails. That is the whole loop: declare the shape, and manni meta holds every file to it.

The single most important decision in a metadata schema is which fields are required and which are merely recommended. JSON Schema models this with one list:

  • Required. List the field in "required". Its absence is a hard failure.
  • Recommended (optional). Describe the field in "properties" but leave it out of "required". If the field is present, manni meta checks its type and format; if it’s absent, that’s fine.

This distinction is what lets you encode a standard that has a strict core and a softer edge. In the example below, type is mandatory, while title and tags are validated when present but never demanded:

metadata.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string", "minLength": 1 },
"title": { "type": "string" },
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
}

Requiring a field guarantees it exists; constraining it guarantees it’s usable. JSON Schema gives you the building blocks, and manni meta supports the standard format checks through ajv-formats. Two formats come up constantly in document metadata:

Use "format": "date-time" to require an ISO 8601 timestamp. This catches the classic fat-fingered date: 2026-13-01, June 5th, or a bare 2026-06-25 where a full timestamp was expected.

"timestamp": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 datetime of last meaningful change."
}

Passes: 2026-06-25T14:30:00Z, 2026-06-25T16:30:00+02:00 Fails: June 5th, 2026-13-01, 2026-06-25T14:30:00

A timestamp needs a zone, either Z or an offset. To require UTC, add "pattern": "Z$" beside the format.

Beyond formats, the everyday constraints carry most of the weight:

  • enum: restrict a field to a fixed set of values, ideal for a controlled type or status: "enum": ["guide", "reference", "tutorial"].
  • minLength: reject empty strings ("minLength": 1).
  • array + items: validate every element of a list, as with the tags example above.

Write each description for the author who fixes it

Section titled “Write each description for the author who fixes it”

A failing finding prints the first sentence of the property’s description under it:

✗ docs/guide.md
/timestamp must match format "date-time" (line 5) [./schemas/page.json]
./schemas/page.json timestamp: ISO 8601 datetime of last meaningful change.

So make that first sentence say what the author needs: what the field is for, and what a good value looks like. Put notes for whoever maintains the schema after it. manni meta schemas show prints the whole description, so nothing after the first sentence is lost.

By default, JSON Schema lets a document carry fields your schema never mentions. Whether to permit that is a deliberate choice, controlled by additionalProperties:

  • "additionalProperties": true (or omitting it): extra keys are tolerated. Contributors can add experimental or tool-specific frontmatter without tripping the check. This is the lenient default and a good fit for a young or shared standard.
  • "additionalProperties": false: every key must be declared in "properties". An unexpected key is a failure. Use this to catch typos like titel: or to keep frontmatter tightly governed.

The schema anchoring manni meta’s default set is Open Knowledge Format (OKF) v0.1, addressed as google:okf:0.1. Run manni meta schemas for the full built-in list. OKF is a useful reference because it combines every technique on this page in a realistic standard. Reading it is the fastest way to see how the pieces fit together. When no other schema applies, manni meta falls back to a set rather than to OKF alone. That set is eleven built-ins. OKF comes first, then passo-uno:seven-action:1.0, which constrains an optional action field and requires nothing. The nine manni vocabularies follow, and manni:core:1.0.0 among them requires title and description.

src/schemas/okf/0.1.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "google:okf:0.1",
"title": "Open Knowledge Format (OKF) v0.1",
"description": "Frontmatter schema for OKF v0.1 concept files. OKF requires only `type`; all other fields are recommended, and unknown keys are explicitly tolerated.",
"type": "object",
"required": ["type"],
"additionalProperties": true,
"properties": {
"type": {
"type": "string",
"minLength": 1,
"description": "A short string identifying the kind of concept."
},
"title": {
"type": "string",
"description": "Human-readable display name."
},
"description": {
"type": "string",
"description": "Single-sentence summary used for indexing and previews."
},
"resource": {
"type": "string",
"format": "uri",
"description": "A URI uniquely identifying the underlying asset."
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Short strings for categorization."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 datetime of last meaningful change."
}
}
}

Notice the design choices OKF makes, each one a decision you’ll face in your own schema:

  • Exactly one required field (type). OKF demands the bare minimum, so adopting it rarely breaks existing files.
  • additionalProperties: true, stated explicitly. Unknown keys are welcome by design, because OKF is meant to be extended.
  • Recommended fields with real constraints. title, description, and tags are optional, but if present they’re checked. resource and timestamp carry uri and date-time formats.
  • A $id and human-readable title/description. Validation does not need these, but they document the schema for the humans who read it. $id is what gives the built-in its google:okf:0.1 address. A $id in a schema file of your own names it only once your config registers the file. Until then you reference it by its file path or URL. Name your schema and its strict version shows the registered form. Give it an id of your own, or leave it out; see the warning below before reusing one.

Everything above assumes you’re defining the field set yourself. manni meta ships fifty-three schemas you can adopt without authoring anything. The built-in schema registry lists all of them in one table, with what each constrains and which eleven are on by default. Twenty-eight are the manni vocabularies, eleven families for maintained docs with a strict overlay each. Stewardship, artifact-evals and terminology each ship at two versions.

If your site is built with Docusaurus, start with docusaurus:docs:3.10 and its blog and pages counterparts. They encode the front matter contract of Docusaurus 3.10 and require nothing. Switching one on cannot fail a page that was already building. It only catches a sidebar_position: "2" that your site would have rejected later. They claim none of the type or action keys below, so they stack with any of the vocabularies. They do share tags with OKF, which allows only strings where Docusaurus also allows tag objects. See the overlap note before pairing those two.

If what you need is a closed list of document types, three of the built-ins are vocabularies:

  • diataxis:diataxis:1.0 requires type and constrains it to the four Diátaxis forms.
  • tgdp:templates:1.1 requires type and recommends the 25 template slugs of The Good Docs Project. Stack tgdp:templates-strict:1.1 beside it to close type to them.
  • passo-uno:seven-action:1.0 constrains action to the seven reader actions of the Seven-Action model.

The first two both claim type, so they are alternatives; either one pairs with the third.

The two type vocabularies each demand the key as well as constraining it, so neither needs a partner for presence-checking. Stack one with OKF for the other OKF fields. Both schemas apply in full, in either order:

Terminal window
manni meta validate docs/ -s diataxis:diataxis:1.0 -s google:okf:0.1

Seven-Action is the exception: it rules that an action, if present, is legitimate, without insisting on one. That is what makes it safe to layer onto what you already have. Every value of all three, which combinations compose, and the crosswalk from Seven-Action to Diátaxis are in the taxonomy schemas reference.

A schema only matters once documents are validated against it. The most direct way to connect the two is to let a document name its own schema with a $schema key in its frontmatter. manni meta reads that key and validates the file against whatever it points to.

Here is a document naming the built-in OKF schema, the schema-ref.md fixture:

schema-ref.md
---
$schema: google:okf:0.1
type: guide
title: Self-Describing Document
---
# Self-Describing Document
This file names its own schema via `$schema`.

The $schema value can be a built-in id (as here), a path to a .json file you wrote, or a URL. To point at a local schema file instead:

---
$schema: ./schemas/metadata.schema.json
type: guide
title: My page
---
  1. Write your schema as a .json file using the patterns above, keeping the "$schema" dialect line at the top.

  2. Add $schema: to a document’s frontmatter, pointing at your file (or a built-in id, or a URL).

  3. Validate it:

    Terminal window
    npx @hawkeyexl/manni meta validate path/to/document.md

    manni meta resolves the document’s $schema, validates the frontmatter, and reports any missing or malformed fields.

A path works everywhere a schema is named, but it has two costs. Every config entry repeats the path. Nothing pairs your schema with a stricter version of it, the way each manni vocabulary pairs with its -strict overlay. meta.register fixes both. It names your schema by its $id, and strict: true stacks the strict version beside it.

  1. Give each schema an id in your own vendor. The id is vendor:name:version, or an absolute https:// URL. The strict version carries the same id with -strict after the name. It narrows what the base already claims, and the two judge a file together.

    schemas/house-page.json
    {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "house:page:1.0.0",
    "type": "object",
    "required": ["team"],
    "properties": {
    "team": { "type": "string", "minLength": 1 }
    }
    }
    schemas/house-page-strict.json
    {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "house:page-strict:1.0.0",
    "type": "object",
    "properties": {
    "team": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
    }
    }

    The vendors manni, check, external, encrypted and derived are reserved, and a built-in id cannot be reused.

  2. Register the directory, and name the base id. Every *.json file under a registered directory registers, however deep.

    manni.config.yaml
    meta:
    register:
    - ./schemas/
    strict: true
    schemas:
    - house:page:1.0.0

    meta.schemas adds to the default set rather than replacing it. Each page now meets the default set, then house:page:1.0.0, then house:page-strict:1.0.0. strict: true also stacks each manni: default’s overlay after it. Set defaults: false to judge pages by your list alone.

  3. Check what registered.

    Terminal window
    npx @hawkeyexl/manni meta schemas

    A Registered schemas section follows the built-ins, with each id beside the file it came from.

  4. Validate. A value the base accepts and the strict version refuses is reported against the strict id:

    ✗ docs/install.md
    /team must match pattern "^[a-z][a-z0-9-]*$" (line 5) [house:page-strict:1.0.0]

The id now works anywhere a built-in id does. A page can carry $schema: house:page:1.0.0, a one-off run can pass -s house:page:1.0.0, and another schema can $ref it. -s judges the named schema alone, so it stacks no strict version. Registration belongs to the config, so under --no-config the id is an unknown schema, exit 2.

To roll the strict version out gradually, leave strict off at the top level and turn it on in one override at a time. An override replaces the set unless it asks for the defaults:

manni.config.yaml
meta:
register:
- ./schemas/
schemas:
- house:page:1.0.0
overrides:
- collection: guides
defaults: true
strict: true
schemas:
- house:page:1.0.0

Some fields your standard requires are not for publishing. An owner, an internal ticket, a service name. Mark the property x-manni-encrypt, and the page has to hold that value encrypted:

schemas/page.json
"owner": { "type": "string", "enum": ["platform", "billing"], "x-manni-encrypt": true }

The field stays required and your enum still holds, because validation checks the decrypted value against the property’s whole schema. Require a field and keep its value private follows one such value through every command that touches it. Put the key where every tool finds it settles where the key lives first.

Some fields are for whoever fetches the page, and some only serve the people and the CI that maintain it. Mark a property x-manni-location to say which:

schemas/page.json
"owner": { "type": "string", "x-manni-location": "external" }

page keeps a value in the document, and external keeps it in the collection’s external-metadata manifest. validate warns about a value on the wrong side, and manni meta relocate moves it. Keep maintainer metadata out of delivered pages follows the mark through every command.