Skip to content

Quill.yaml Reference

Complete reference for authoring Quill.yaml configuration files. For a hands-on introduction, see Creating Quills.

File Structure

A Quill.yaml has these top-level sections:

quill:        # Required: format metadata
  ...

main:         # Optional, main entry-point card: field schemas and optional ui/body
  fields:
    ...
  ui:         # optional UI hints (e.g. title)
  body:       # optional body-region config (e.g. enabled, example)

card_kinds:   # Optional: additional composable card kinds
  ...

typst:        # Optional: backend-specific configuration
  ...

Root-level fields: is not supported; define the main document's field schemas under main.fields.

Quill.yaml is parsed strictly. Unknown keys in the quill: section, unknown top-level sections, malformed ui: blocks, and field schemas that can't be parsed all produce errors: they are never silently dropped. Every error is collected in a single pass, so authors see all problems at once. Run quillmark validate <quill_dir> to surface them.


quill Section

Every Quill.yaml must have a quill section with format metadata.

quill.name must be snake_case (^[a-z][a-z0-9_]*$).

Key Type Required Description
name string yes Unique identifier for the Quill
backend string yes Rendering backend (e.g. typst)
description string yes Human-readable description of the quill itself (non-empty). Independent of main.description, which is the optional schema description authored under main:.
version string yes Semantic version (MAJOR.MINOR or MAJOR.MINOR.PATCH)
author string no Creator of the Quill (defaults to "Unknown")
ui object no Document-level UI metadata

A backend's own settings live under its backend-named section, not in quill:. The Typst template, for example, is declared as typst.plate_file (see the typst Section below).

quill:
  name: usaf_memo
  version: "0.1"
  backend: typst
  description: Typesetted USAF Official Memorandum
  author: TongueToQuill

typst:
  plate_file: plate.typ

main Section

The main document card holds root-block field schemas under main.fields. Optional main.description describes the schema itself (independent of quill.description, which describes the quill package). Optional main.ui sets container-level UI for that card. quill.ui is a fallback for main.ui, not a merge: any main.ui (even an empty ui: {}) wins wholesale, and quill.ui applies only when main.ui is absent.

Field order under main.fields is display order in UIs: the declaration order of the keys is carried structurally through parsing and schema emission, so consumers walk the fields in key order. There is no ui.order knob: to reorder fields, reorder them in Quill.yaml.

Field keys must be snake_case (^[a-z][a-z0-9_]*$). Capitalized field keys are reserved.

A card declares at most 1000 fields — the width one card-yaml block carries — and past it the quill fails to load (quill::too_many_fields). The count is per card, and only these top-level keys are charged: a nested properties map, an array's items, and a variants: cell set all ride inside the one field that declares them.

main:
  fields:
    subject:          # Field name (used as the card-yaml payload key)
      type: string
      description: Be brief and clear.

Field Properties

Property Type Required Description
type string yes Data type (see Field Types)
description string no Detailed help text
default matches type no The value the majority of authors want. When the cell is omitted, the default is filled in — at any depth, whether or not the container above it was authored — and the blueprint renders that concrete value with a type-only annotation, shippable as-is. Declaring it also makes the field unobliged (see Obligation). Declared on a cell: a leaf or an array. On an object it is a load error (quill::default_on_namespace), since its properties hold their own.
example matches type no A value matching the type and shape of what the author wants, but not the value desired most of the time. Documents shape only, never rendered as the value: it takes the blueprint cell when no default holds it, and surfaces in the # e.g. line otherwise. Declared on a cell, as default is (quill::example_on_namespace).
values array of strings for enum The closed set of allowed string values: the choices. Required on every enum field. Declaring "" is a load error — every enum also accepts its blank, which the engine supplies.
ui object no UI rendering hints (see UI Properties)
items object for array Element schema for an array field (a nested field schema). Required on every array.
properties object for object Nested field schemas for an object typed dictionary (or an array's object-typed items). Required on every object field.
inline boolean no For richtext and plaintext only: constrain the content to a single paragraph/line (a one-line editor surface).

Obligation

One question per field: does it have a value when nobody types anything? default is the answer, and its absence is what obliges a human.

# Optional, with nothing to suggest: the type's blank is the answer "nothing".
internal_note:
  type: string
  default: ""

# A suggested marking a human must still confirm: no default, so the cell is
# obliged, and the example fills it under the marker.
classification:
  type: enum
  values: [UNCLASSIFIED, CUI]
  example: UNCLASSIFIED

There is no must_fill: key: declaring one is a load error. Write the default: or example: that carries the obligation instead.

An obliged field is one that carries the !must_fill marker in the blueprint, is stamped when seeding commits its example, and raises the non-fatal validation::must_fill warning from Quill::validate while the document leaves it unauthored. Three things discharge it: authoring a value, authoring the field's blank (deliberately nothing is an answer), or dropping the marker by hand.

It is an affordance, not a submit gate. If you arrive from web forms, the familiar half transfers — the editor knows which fields to mark — and the enforcement half does not. An unfilled must-fill field renders; nothing refuses it. "Must pick" is this warning plus whatever policy a consumer layers on top, canonically a strict consumer treats any outstanding marker as not done. There is no required: and no severity knob: on this surface Severity::Error already means "won't render".

On a typed dictionary the key is inert on the container — the obligation lives on its leaves, which is where the blueprint marks and the warning anchors. An array is its own cell, so [] is an authored answer that discharges it.

Field Types

Type Notes
string Open scalar UTF-8 text: a value the template computes with (a URL, path, identifier, or reference key), not prose it lays out
enum A closed set of string values; requires a values: list. Also accepts its blank (""), which is not a declared member. Projects to JSON-Schema {type: string, enum: ["", …]}
plaintext Navigable, unformatted prose over the canonical content: the same nav/regions as richtext, but a literal codec (delimiters stay literal, no markup). Add inline: true for the single-line variant
number Numeric scalar (integers and decimals)
integer Integer-only numeric scalar, sized as an i64; a literal past that range takes number
boolean true or false
array Ordered list; requires an items: element schema. Optional max:, the element count the page holds (see max)
date A strict calendar date YYYY-MM-DD; rejects any time component
datetime A strict offset-less wall-clock datetime YYYY-MM-DDThh:mm[:ss]; rejects offsets, the space separator, fractional seconds, and bare dates
richtext Rich, formatted prose over a canonical content; backends lower it to the target format. Markdown is its import/export projection. Add inline: true for the single-paragraph variant
object Structured map; requires a properties: map
matrix A closed vocabulary the author ticks; requires a members: roster. Each member is an object of a synthesized held plus the field's properties: (see Matrix)

Choosing among string, enum, plaintext, and richtext

The four text-ish types form a 2×2 of data vs content × open/plain vs closed/formatted, and two questions resolve it:

  1. Does the author write prose here, or does the plate compute with the value? Prose is content; a name, URL, path, identifier, or reference key is data.
  2. Then: is the domain closed (enum over string), or should markdown delimiters format the text rather than stay literal (richtext over plaintext)?
data — the plate computes with it content — the author writes and navigates prose
open / literal string plaintext: *text* stays literal
closed / formatted enum: a values: domain richtext: *text* becomes emphasis

A content field rides the canonical content model, so it carries navigation, regions, and click-to-edit in editor consumers; string and enum carry none of that. plaintext and richtext share that entire stack and the same backend lowering, so they are indistinguishable in an editor and diverge only at emit, where the codec decides whether a delimiter is markup or a character.

Changing a declared type reinterprets every stored value in that field at the next bound load, with no diagnostic, and data → content is the lossy direction: the stored string enters the codec's import and its delimiters are consumed as structure, leaving the literal characters unrecoverable. A declared type change is a new quill version (Quill Versioning); audit the corpus before publishing one, as under Date and Datetime Grammars.

Date and Datetime Grammars

The two grammars are disjoint: date rejects any time component, datetime rejects a bare date, and neither truncates. A field holding a mix of 2026-06-01 and 2026-06-01T09:30 has no correct declaration, since whichever type it takes strands the other half. Normalize the values, or split the field in two.

Coercion runs when the document lowers into the backend's data, upstream of the plate. A value outside its declared grammar fails the render before any template code executes, so a plate that never mentions the field fails identically and no plate-side coercion repairs it. quill.validate(doc) is what names the field and its path. A date or datetime field is a scalar, so quill.conform(doc) passes it in silence: the conform walk covers content fields only.

Changing a declared date type on a deployed corpus is a corpus operation, not a schema edit. The stored string is never rewritten: neither the transport door nor conform touches a scalar. Nothing is lost, and every document holding a value the new grammar rejects strands at render. Audit before publishing the change: load each stored row through the transport door, read the field, and test it against the target grammar. reader.get returns the stored string verbatim for both date types.

const doc = Document.fromStored(row);
const value = quill.reader(doc).get('issued');
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
  // Strands under `type: date`. Repair before the schema change ships.
  quill.writer(doc).set('issued', value.slice(0, 10));
}

writer.set refuses the same values the render does (edit::field_coercion_failed), so the repair writes the corrected string rather than the original.

Enum Constraints

Declare a closed string domain with type: enum and a required values: list:

main:
  fields:
    format:
      type: enum
      values:
        - standard
        - informal
        - separate_page
      default: standard
      description: "Format style for the endorsement."

values: on any other type is a load error, as is the retired enum: modifier on any type.

The blank: values: is for choices, not for the absence of one

Every enum accepts one value beyond its values: list — the blank, spelled "". The engine supplies it; you never declare it, and declaring "" in values: is a load error (quill::enum_blank_member).

That is what a document says when nobody has answered. A field with no default: renders its blank rather than the first variant, so reordering values: never changes what an unanswered document renders, and no reader ever sees a choice nobody made.

The two keys range over different sets, which is why "" is rejected in one and accepted in the other:

    classification:
      type: enum
      values: [UNCLASSIFIED, CUI, SECRET]   # the choices — no "" here
      default: ""                           # the blank — a value, and legal

values: enumerates choices. default:, your documents, and the projections all range over values ∪ blank. Keeping default: "" is how you say "this field is optional"; dropping it makes the field one an author is expected to answer.

Where the empty state is itself a decision someone makes and the document should record it, make it a member — undecided, waived, n_a — not the blank. The blank means nobody chose; a member means someone chose "none".

Name the blank's label with ui.blank_title when a bare empty row would read badly; absent one, consumers supply their own conventional label.

If you arrive from web forms, your prior transfers: this is HTML's placeholder <option value="">, Django's ("", "---------"), Rails' include_blank:. One caveat — the affordance carries over, the enforcement does not. There is no required:; an unanswered field is a warning plus consumer policy, never a load or render failure. If you arrive from protobuf, your prior is a near-miss: proto3 reserves slot 0 inside the enum (FOO_UNSPECIFIED = 0), whereas here the sentinel lives outside the domain and your values: list stays clean.

Writing a plate against an enum: branch over values ∪ blank exhaustively. An else fallback silently renders a variant nobody chose, and data.at(key, default: X) is not a guard — every declared key is always present at render, so its default: never fires and the blank flows through.

Variants: fields that exist only for one choice

Some fields belong to one choice and are meaningless beside any other. Declare them under variants:, keyed by the member that brings them into play:

    classification:
      type: enum
      values: [UNCLASSIFIED, CUI, CONFIDENTIAL, SECRET, TOP SECRET]
      default: ""
      variants:
        CUI:
          controlled_by: { type: string }        # obliged, but only on a CUI memo
          poc:           { type: string }
          category:      { type: string, default: "" }

The field then holds a container instead of a bare string, and a document writes the choice under value with that world's answers beside it:

classification:
  value: CUI
  controlled_by: SAF/AA
  poc: Capt J. Smith, DSN 555-1234

A world with nothing to fill in still writes plainly — classification: UNCLASSIFIED is accepted and means the same as {value: UNCLASSIFIED}.

The container is a document shape. The schema's own default: and example: name the discriminant alone (default: "", example: CUI); a container-shaped one is a load error, because each cell in a world carries its literal on its own declaration.

Three things follow, and they are the reason to reach for this over a cui_-prefixed row of flat fields:

  • The names shorten. The prefix was hand-written namespacing; nesting supplies it structurally, so cui_poc becomes poc.
  • must_fill becomes conditional. controlled_by declares no default:, so it is obliged — but only where classification reads CUI. On every other memo the same schema asks for nothing. That is the one cross-field rule the engine checks rather than describes in description: prose.
  • Editors know. The schema says which cells are out of play, so a form retires them instead of showing a CUI block on an unclassified memo.

Writing a plate against variants. The live world's fields arrive only inside the branch that selects it, which is the branch you already owe every enum:

..if data.classification.value == "CUI" {
  (controlled_by: data.classification.controlled_by, poc: data.classification.poc)
},

Inside that branch every declared field of the world is present and blank-filled, so no guarded access is needed. Outside it there is nothing to read: an unanswered classification renders {value: ""}, and the blank brings no field set at all.

Flipping the choice keeps the answers. Selecting UNCLASSIFIED after filling in a CUI block leaves those values in the document and warns (validation::out_of_variant); they simply stop rendering. Flip back and they are still there. Remove the field to drop the value for good.

A variant cell is an ordinary field: any type a card field may carry, prose, dates and containers included, reaching the plate exactly as a card-level one does. What it cannot carry is variants: of its own. variants: itself is valid only on a card-level type: enum field, keys only on declared members (never "", which owns no field set), and cannot declare a field named value. Variant fields inherit the discriminant's ui.group; declaring one inside a variant is an error. A field set shared by several members is repeated or shared with a YAML anchor — a variant keys on one member — but every variant declaring a given name must declare it identically, since the name is one cell of the container whichever world brings it into play (quill::variant_field_collision).

Primitive Arrays, Typed Tables, and Typed Dictionaries

Every array declares its element type under items:. For a primitive list, give items a scalar type, coercion and validation then apply element-wise (e.g. each element of an integer[] is coerced to an integer, and a bad element fails at its indexed path like counts[1]):

main:
  fields:
    tags:
      type: array
      items:
        type: string
    counts:
      type: array
      items:
        type: integer
    sections:
      type: array
      items:
        type: richtext   # each element's content is lowered to backend markup

For a typed table (a list of structured rows) give items an object type with its own properties:. Coercion recurses into each element and converts property values to their declared types:

main:
  fields:
    cells:
      type: array
      items:
        type: object
        properties:
          category:
            type: string
          score:
            type: number

Use type: object with properties: for a single structured mapping:

main:
  fields:
    address:
      type: object
      properties:
        street:
          type: string
        city:
          type: string

Containers nest freely: a property or an element is an ordinary field, so it carries whatever type a card-level field carries, itself included. object<array<string>>, array<array<integer>> and a typed table whose row holds a typed dictionary are all declarable, and each leaf is addressable by the schema address its path spells (contact.address.city, grid.0.0 — see PLATE_DATA.md).

A dictionary holds no default: or example: of its own — its properties do:

address:
  type: object
  # default: { street: "5000 Forbes Ave" }   # quill::default_on_namespace
  properties:
    street: { type: string, default: "5000 Forbes Ave" }
    city:   { type: string, default: "" }    # type-empty: a skippable cell

The container spelling would be a second declaration of a value the property already holds, and the two disagree: must_fill derives per property, so a container default renders a value and still reports the cell unauthored. Each property's default: is reached whether or not a document authors the container above it, so writing address: {} changes nothing — which also makes a whole dictionary skippable by giving each property a type-empty default.

An array does keep its own default: / example:: items: fixes the element type but never the arity, so default: [] and default: [{…}] say something no property declaration can. Each element it supplies is completed against items exactly as an authored element is.

Two keys are card-level regardless of depth: ui.group (grouping never descends) and variants: (see Variants).

max: what the page holds

A form laid out on a page holds a fixed number of rows. Say so with max:, and the limit stops being prose only a human reads:

experience:
  type: array
  max: 37                 # the form's 37 ruled lines
  items:
    type: object
    properties:
      duty: { type: string, default: "" }

max: is a non-negative integer, valid only on an array, and a default: or example: longer than it fails to load. A document holding more elements draws a warning, validation::cardinality, at the field's path with max and actual in its args — never an error: the document still renders, and the plate decides what happens to the surplus. Each declaration carries its own cap, so a capped array nested inside a row is checked against its own.

The blueprint shows the cap as an own-line # up to 37 under the field's description, which is where an MCP author reads it before writing.

There is no min:. Obligation is default:'s absence (Obligation), so min: 1 would be required: under another name, contradict a sibling default: [], and double-report with must_fill on an absent array.

Matrix: a vocabulary the author ticks

Some fields are neither a list the author grows nor a choice they make once: they are a fixed vocabulary printed in full, where the author ticks what they hold and may annotate a tick. type: matrix is that shape.

qualifications:
  type: matrix
  description: Tick each qualification held.
  members:                      # ordered; `group:` optional; `id: Title`
    - group: Leadership & Command
      values:
        sq_cc_candidate: Sq/CC Candidate
        flight_cc: Flight CC
    - group: Operations
      values:
        dodin_ops: DODIN Ops
  properties:                   # the columns; omit them for a plain checklist
    detail:
      type: plaintext
      inline: true
      default: ""

Every member becomes an object of a synthesized held (a boolean, false by default) plus the declared columns. Member ids are snake_case and must be unique: they are what the wire, the address and the document speak, while the title is display only. The three keys the matrix writes itself — held, title, group — cannot be column names.

A document ticks sparsely, and key presence is the tick:

qualifications:
  flight_cc: true                  # held; columns take their defaults
  dodin_ops: { detail: "2024" }    # held, with columns
  cyber_200: { held: false, detail: kept }   # not held; the detail is kept in the file

A member the roster does not declare is refused, as an out-of-domain enum value is. A mapping has one slot per key, so a member cannot be ticked twice.

Your plate writes one loop and no vocabulary of its own, because every member arrives on every render:

#for (id, m) in data.qualifications {
  [#(if m.held { "[x]" } else { "[ ]" }) #m.title  #m.group #m.detail]
}

Three things to author against. Declare a default: on each column and the matrix is skippable; leave one off and it is required inside a ticked member, which is how "required when held" is spelled. The matrix itself takes no default: or example: and seeds empty, so a fresh document ticks nothing and the blueprint shows the vocabulary in the field's annotation, # matrix<sq_cc_candidate | flight_cc | dodin_ops>. And each cell is an ordinary address — qualifications.flight_cc.held regions on Typst and binds a checkbox on acroform — so an editor unticks by writing held: false rather than by dropping the key, and the detail survives.

Full model: SCHEMAS.md; the wire shape is PLATE_DATA.md.


UI Properties

The ui property on fields controls how form builders and wizards render the field. These are UI hints, not validation constraints.

title

Overrides the display label shown next to the input. Form builders derive a label automatically from the snake_case field key (memo_for → "Memo For"), so ui.title is only needed when that automatic label is wrong or misleading:

main:
  fields:
    memo_for:
      type: array
      items:
        type: string
      ui:
        title: To       # "Memo For" would confuse users unfamiliar with memo conventions

group and the group registry

Groups organize fields into visual sections. A group has two parts: a registry declared once on the card (ui.groups), and a per-field reference (ui.group) into that registry.

main:
  ui:
    groups: [addressing, letterhead]   # declaration order = display order
  fields:
    memo_for:
      type: array
      items:
        type: string
      ui:
        group: addressing              # a reference, validated against the registry

    memo_from:
      type: array
      items:
        type: string
      ui:
        group: addressing

    letterhead_title:
      type: string
      ui:
        group: letterhead

The registry is the card's table of contents. Its keys are snake_case ids (same discipline as field keys), and their declaration order fixes the group display order: the contract every consumer follows, exactly as field declaration order fixes field display order. A field's ui.group names one of those ids; a value with no matching registry key is a load error (quill::unknown_group), so a one-character typo cannot silently split a section.

Identity is the id, not the label. Consumers derive a group's display label from its id (addressing → "Addressing"), just as a field label is derived from its key. Override the derived label with title:, which requires the mapping form of the registry:

main:
  ui:
    groups:
      addressing: {}                       # label derived: "Addressing"
      letterhead: { title: "Letterhead & Seal" }   # label overridden

The two registry forms are interchangeable: a bare sequence of ids ([addressing, letterhead]) when no labels need overriding, or a mapping of id → attributes when they do. Renaming a label touches one line and never breaks a ui.group reference or persisted per-group editor state.

group applies only to card-level fields (those directly under a card's fields:). Grouping never descends into an object's properties or an array's items, so a group on a nested property is a hard error (quill::nested_group_not_supported) rather than a silently inert knob.

The registry is required: a ui.group on a card with no ui.groups is a load error (quill::implicit_group). A group has one declaration site, and it is the registry.

field order

Field display order is declaration order: the order the keys appear in Quill.yaml. This holds at every level: card-level fields, and the properties of a typed dictionary or typed-table row. The order is carried structurally (the schema's field maps preserve key order, and schema() re-emits that order), so no per-field knob is involved.

There is no ui.order key: an authored ui: { order: N } is a load error (quill::field_parse_error). To move a field, move its block in Quill.yaml.

blank_title

Labels an enum's blank option in a picker. Absent, consumers render a conventional label of their own, so this is only worth setting when a bare empty row would read badly:

main:
  fields:
    classification:
      type: enum
      values: [UNCLASSIFIED, CUI, SECRET]
      default: ""
      ui:
        blank_title: "(no marking)"

It labels the blank, never a member: values: carries no entry for it. Consumers must keep the blank selectable and re-selectable — returning to it is how an author clears a cell back to unset, so a disabled placeholder that vanishes once a choice is made is the wrong idiom.

compact

When true, the UI renders this field in a compact style (smaller vertical footprint).

main:
  fields:
    tag:
      type: string
      ui:
        compact: true

multiline

Controls the initial size of the text input for string and richtext fields. When true, the UI starts with a larger text box instead of a single-line input:

main:
  fields:
    summary:
      type: richtext
      description: Executive summary
      ui:
        multiline: true   # start as a larger text box

    notes:
      type: string
      description: Free-form notes
      ui:
        multiline: true

    tagline:
      type: richtext
      description: One-sentence tagline
      # no multiline: single-line input that expands on demand

Meaningful on string and richtext fields; ignored on other types.

layout

Names the control a field asks an editor to draw, where the shape admits more than one and the default reads wrong. table is the only value, and it is valid only on a typed table — an array whose items is an object:

main:
  fields:
    tours:
      type: array
      ui:
        layout: table       # a grid: one row per element, one column per property
      items:
        type: object
        ui:
          title: "{unit}"   # the collapsed row's summary line
        properties:
          unit:     { type: string, default: "" }
          duration: { type: integer, default: 0 }

Without it, an editor draws a typed table as a stack of collapsed rows that open one at a time — right for a row of long prose, wrong for four short cells.

It is a request, not a contract. An editor may decline it — a row holding a block richtext or a container, a width that will not hold the columns — and fall back to the record list. Nothing else reads the key: the plate, validate and the blueprint are all deliberately inert on it, and schema() echoes it verbatim for the editor to find. On any other field it is a load error, quill::invalid_ui.

title on items

A row's items.ui.title is the summary line a collapsed row shows, the same {property} template a card kind's ui.title takes, interpolated with that row's live values. It is the row-level counterpart of a card's title, so a list of rows reads as a list of things rather than a list of "Item 1, Item 2".


card_kinds Section

card_kinds define composable, repeatable content blocks (the kinds: a document can then carry zero or more instances of each kind, interleaved with body content). Each entry is shaped exactly like main: (fields, optional description, ui, body); think of main: as the single mandatory card-kind for the document body, and card_kinds: as the library of additional kinds that may attach to it.

Card-kind names (the keys under card_kinds) must match [a-z_][a-z0-9_]* (leading underscore is allowed).

card_kinds:
  indorsement:                    # Card-kind name
    description: Chain of routing endorsements.
    fields:
      from:
        type: string
        ui:
          group: Addressing
      format:
        type: enum
        values: [standard, informal, separate_page]
        default: standard

Invalid card-kind names include:

  • BadCard (uppercase letters)
  • my-card (hyphen)
  • 2nd_card (starts with a digit)

When not to declare one

A card is a part someone writes; a repeated record someone fills in is a row, and a row is an array<object> field on the card that owns it — not a card kind. The test is a disjunction: a unit that stands in document order among units of other kinds, or whose substance is flowing prose, is a card; otherwise it is a row.

A kind declaring body.enabled: false draws a warning, quill::bodiless_card_kind, pointing at the row shape. It is advice and not a refusal: no body is only a proxy for "not prose", and a bodiless kind that truly interleaves — a page break, a rule, an inserted signature block, all of which exist for their position — is a card by the first clause, which no schema can state. main is never warned: a form has no root prose.

Rows are cheaper where they fit. They nest lexically, so a row belongs to its parent by position in the file rather than by counting cards in the stream, and a plate reads card.rows instead of reassembling a run. What rows give up is the fence-native body: a row's prose is a richtext cell, one quoted YAML scalar, which is fine for a few lines and wrong for a memo's worth.

Card Properties

Property Type Required Description
description string no Help text describing the card's purpose
fields object no Field schemas (same structure as top-level fields)
ui object no Container-level UI hints (see Card-level ui)
body object no Body-region config (see Card-level body)

Card-level ui

Property Type Description
title string Display label for the card kind. Literal string or {field} template

Card-level body

Property Type Description
enabled bool Whether the body editor is enabled (default: true). When false, consumers must not accept or store body content for this card kind.
example string Default body text used when seeding a card of this kind and shown in the blueprint body region; falls back to Write <kind> body here. when absent.

title

A human-readable display label for the card kind. UI consumers should prefer it over the snake_case map key when rendering section headers, chips, picker entries, or per-instance titles in a list.

The label is decoupled from the map key (e.g. indorsement), which is the on-the-wire $kind discriminator. Authors can rename the label freely without invalidating stored documents.

Two flavors:

A literal string serves as a static type label:

card_kinds:
  indorsement:
    ui:
      title: Routing Endorsement
    fields:
      from:
        type: string

A template containing {field_name} tokens lets UI consumers produce a per-instance title by interpolating live field values:

card_kinds:
  endorsement:
    ui:
      title: "{from}  {for}"
    fields:
      from:
        type: string
      for:
        type: string

With the template form, a UI rendering a list of cards can title each instance (e.g. "ORG1/SYM → ORG2/SYM") instead of falling back to a generic "Card (2)".

Interpolation rules (for UI consumers): - {field_name} is replaced with the current value of that field. - A title with no {} tokens is rendered verbatim: it's just a literal label. - If a referenced field is absent or empty, the token resolves to an empty string. - UI consumers are responsible for trimming degenerate separators (e.g. ": " with one empty side).

When omitted, UI consumers fall back to the prettified map key.

body.enabled

When false, the card kind has no body/content area. Consumers must not accept or store body content for instances of this card kind. The validator enforces this: a document instance that provides body content for a body.enabled: false card kind is rejected with a BodyDisabled error.

card_kinds:
  page_break:
    body:
      enabled: false    # Card has fields only, no body/content area
    fields:
      style:
        type: string

Loading this draws quill::bodiless_card_kind, which asks whether the kind is a card at all: a repeated record with no prose is a row (see When not to declare one). The example above stands because a page break exists for its position in the stream, which no row can occupy. main is never warned.

body.example

Default body text seeded into a card of this kind and shown verbatim in the blueprint body region (it falls back to Write <kind> body here. when absent). Has no effect when body.enabled is false.

card_kinds:
  experience:
    body:
      example: Describe your role, responsibilities, and key achievements.
    fields:
      company:
        type: string

body.unsupported

The block constructs your plate does not typeset in this body. Empty by default: declare nothing and nothing changes.

A plate is free to reinterpret a construct — absorb it into a neighbour, move its text, typeset nothing at all — and only you know it did. Declaring it here says so once, as data, and buys two things: an editor reads it off the schema and can decline the gesture before the author makes it, and a body holding the construct anyway (from an import, a repack, the CLI) draws a plate::unsupported_construct warning on the pre-render walk. The warning is non-fatal and carries the body's path, the construct name, and how many the body holds.

main:
  body:
    # This template has no dividers, at any depth.
    unsupported: [rule]
  fields: {}

Valid names: heading, rule, code, list, quote, table, image. The set is closed, so a misspelling is a load error rather than a declaration that quietly matches nothing. There is no paragraph name: a paragraph is the floor and cannot be declined, and there are no context-qualified forms (a heading is declined everywhere or nowhere).

Nothing verifies a declaration against what your plate actually does. It is documentation with a diagnostic attached: declaring rule does not make rules disappear, and omitting a construct your plate drops keeps that drop as silent as before.

Using Cards in Markdown

Card kinds defined here are authored as ~~~ blocks (with a $kind: <kind> line) in the document body. See card-yaml Blocks for the markdown syntax.


typst Section

Backend-specific configuration for the Typst renderer.

Key Type Required Description
plate_file string no Path (relative to the quill root) to the Typst template the backend compiles
packages array no Typst packages the template depends on
typst:
  plate_file: plate.typ
  packages:
    - "@preview/appreciated-letter:0.1.0"

See the Typst Backend Guide for details.


Reading the schema programmatically

Quillmark emits a public schema contract derived from Quill.yaml. Accessors:

  • Rust: QuillConfig::schema() (JSON) / QuillConfig::schema_yaml() (YAML)
  • Python: quill.schema (structured dict)
  • WASM: quill.schema (JSON)
  • CLI: quillmark schema <path>

ui: hints are preserved verbatim in the output. See SCHEMAS.md for the emitted shape.


Complete Example

quill:
  name: project_report
  version: "1.0"
  backend: typst
  description: Monthly project status report
  author: Engineering Team

typst:
  plate_file: plate.typ

main:
  fields:
    project_name:
      type: string
      ui:
        group: Header

    status:
      type: enum
      values: [on_track, at_risk, blocked]
      ui:
        group: Header

    risk_description:
      type: string
      default: ""
      ui:
        group: Header
      description: Describe the risk or blocker. Only needed when status is not on_track.

    date:
      type: date
      ui:
        group: Header

    team_members:
      type: array
      items:
        type: string
      default: []
      ui:
        group: Team

    budget:
      type: number
      default: 0
      ui:
        group: Financials

card_kinds:
  milestone:
    description: A project milestone with target date and status.
    fields:
      name:
        type: string
      target_date:
        type: date
      completed:
        type: boolean
        default: false

Next Steps