Skip to content

Build a library import file

Ferrith ChatFor authorsFor adminsVerified 2 Oct 2026

A drafting library moves between workspaces as one file: a zip holding two JSON files. Export on a document type's card writes it, and Import a library reads it. Inside, everything refers to everything else by its key, never by an id from a workspace, so you can write the file yourself: turn a precedent your firm already uses into a first library, restructure a library in a text editor, or keep a copy of your library with your other precedents.

Nothing an import creates is used until you say so. Every block arrives as a draft, and the document type and its templates arrive restricted to you, so a file you have written can be imported, checked and corrected before anyone drafts from it.

Before you start

  • Importing needs the author documents grant (admins have it) and a plan that includes document drafting.
  • You need a text editor that saves UTF-8, and a way to make a zip (make the zip shows how).
  • A block's text is written in the block language. Keep the block language reference open while you write.
  • A complete file to copy from helps. The example on this page is short; the feature tour example uses every feature of a document type and its blocks. Unzip either to see a whole file.

What's in the file

text
engagement-letter.zip
├── manifest.json
└── library/
    └── library.json
  • manifest.json says what the file is. It sits at the top of the zip.
  • library/library.json holds the library: one document type, its fields, its blocks, its templates and its selection tests. It sits in a folder named library.

Both are JSON in UTF-8, and the names of both files and the folder are exact. Write the property names as this page shows them. The import ignores a property it doesn't recognise, so a misspelt one is quietly treated as missing: check the file against the schema before you import it.

The manifest

Property Type Required What it holds
formatVersion number Yes 1, the current format. A higher number is refused as coming from a newer version of Ferrith.
kind text Yes library.
name text Yes The library's name, shown when you import it.
exportedAtUtc date and time No When the file was written, for your own records.
entityNames list of text No The names of the document type and its templates, for your own records.
notices list of text No Notes shown to the person importing, before and after the import: what to check, what to change first.

The library

library/library.json is one object with five lists:

Property What it holds
documentTypes The document type, as a list of one. Required.
fields The questions the type's documents ask.
blocks The building blocks: their text and the rules between them.
templates Fixed recipes of blocks for the type.
selectionTests Briefs that measure how well the AI picks blocks for the type.

A property you leave out takes its default: an empty list, no value, or false. Keys come in two shapes:

  • A field key is lower-case words joined by dots and underscores, at most six words and 64 characters: client.full_name. The dots only group related fields. The first word can't be one of the block language's own names: document, page, money, words, ordinal, format_date, names, list, ref, image, anchor, page_break, string, array, math, date, object, regex, timespan, html, if, else, end, for, in, while, case, when, func, ret, with, wrap, capture, include, import, readonly, true, false, null, empty, tablerow, break, continue, this or loop.
  • Every other key, for a document type, a section, a field group or a block, is lower-case letters, digits and hyphens, at most 64 characters: engagement-letter.

The document type

Property Type Required What it holds
key key Yes The type's key.
name text Yes The name members see.
description text No What the type is for.
numbering text Yes The clause numbering: continuous (1, 2, 3 through the document), by_section (3.1, 3.2, restarting in each section) or none.
listNumbering text No plain (a block's numbered lists print as written; the default) or legal (they print as sub-paragraphs under the clause: 4.7.1, then (a), then (i)).
numberSectionHeadings true / false No With numbering set to by_section, true puts a numbered section's number in its heading: "3. Letting and rent" over clauses 3.1 and 3.2.
sectionHeadingLevel whole number No How large the section headings print, from 1 (the size of the document's title) to 4; a schedule's parts print one size smaller. Left out, 2.
sections list No The headings, in order. See below.
mandatoryBlockKeys list of keys No The blocks every document of the type includes.
fieldGroups list No The headings the form asks its questions under. See below.
titlePattern text No The document's title, with merge fields: Engagement letter for {{ client.name }}.
hideTitle true / false No true leaves the title line out of the document.
titleAlignment text No left (the default), centre or right.
header, footer text No Printed on every page of an export, written in the block language. {{ page.number }} and {{ page.count }} print the page numbers; a cross-reference can't be used.
headerOnFirstPage true / false No true prints the header on the first page too. Left out, the first page has no header.
selectionGuidance text No Guidance for the AI when it proposes blocks for the type.
draftBanner true / false No true marks the text as a draft until the document is reviewed.
requireReviewBeforeExport true / false No true holds every export until someone marks the document reviewed.
reviewers list No Who may review: admins, owner or both. Named people and roles can't travel in a file; add them in the library after the import.

Each section:

Property Type Required What it holds
key key Yes Blocks name the section they sit under by this key.
title text Yes The heading.
numbered true / false No true numbers the blocks in the section; a schedule's or a part's are numbered from 1. Left out, they print without numbers.
hideTitle true / false No true leaves the heading out, for a section whose blocks supply their own.
kind text No clause (the default), schedule (labelled Schedule 1, 2 as it prints) or part (a part of a schedule, labelled Part 1, 2 under it).
parentKey key For a part The key of the schedule the part belongs to, listed above it.
newPage true / false No true starts the section on a new page in the PDF and Word exports.

Each field group:

Property Type Required What it holds
key key Yes The group's key.
title text Yes The heading on the form.
fieldKeys list of field keys Yes The questions under the heading, in order. Each must be a field in the file, or one your workspace already has, and a field can be in one group only.

Fields

Property Type Required What it holds
key field key Yes How blocks merge the answer: {{ client.name }}.
label text Yes The question, as the form shows it.
help text No A line of help under the question.
kind text Yes text, textarea (long text), date, integer (a whole number), number, money, boolean (yes / no), choice (one of a list), choices (several from a list), person, persons (people), address or image.
options list of text For choice and choices The answers to choose from: 1 to 200, each different.
validation object No Rules for the answer. See below.
documentTypeKeys list of keys No The document types the field belongs to. An empty list makes it available to every document type in the workspace.
defaultValueJson text No A default answer for every new document. See below.
allowUserChange true / false No With a default: true lets the person drafting change it; false, or left out, makes it fixed, read-only on the form.
imageWidthMm number No For an image: the printed width in millimetres, 5 to 170. Left out, 40.

The rules in validation are all optional:

Property For What it sets
minLength, maxLength text, long text, address The shortest and longest answer, in characters.
min, max whole number, number, money The lowest and highest answer.
pattern text, long text, address A regular expression the answer must match. It matches anywhere in the answer: start it with ^ and end it with $ to test the whole answer.
minItems, maxItems several from a list, people The fewest and most answers.

A default answer is written as JSON and then stored as a string, so a text default keeps its own quotation marks, escaped with a backslash. It must be a valid answer to the field and pass the field's rules; an invalid default refuses the import.

Kind Write the default as defaultValueJson
text, long text, address text; \n between an address's lines "\"Example Firm LLP\""
date year, month and day "\"2026-10-01\""
whole number, number, money a number "30", "1500.50"
yes / no true or false "true"
one of a list one of the options "\"Fixed fee\""
several from a list a list of options "[\"Email\", \"Post\"]"
person a name and an address "{\"full_name\": \"Jane Smith\", \"address\": \"1 High Street\"}"
people a list of persons "[{\"full_name\": \"Jane Smith\"}]"
image nothing: pictures don't travel in a file null; upload the default on the field after the import

A text, long text, address, date or one-of-a-list default written without the inner quotation marks, "Fixed fee", is read as text too.

Blocks

Property Type Required What it holds
key key Yes How other blocks, templates and tests name the block.
title text Yes The block's title in the library.
description text No What the block says. The AI reads it, with useWhen, when it proposes blocks: write both for a colleague who has never seen the block.
useWhen text No When to choose the block, and over which alternative.
script text Yes The block's text in the block language. A new line in the text is a new line in the document; in JSON, write it as \n.
guidance text No Guidance for the person drafting, shown with the block.
placements list To publish Where the block sits in each document type. See below. A block with no placement imports but can't be published.
includeWhen text No The plan's test over the answers. A yes / no compares with `true` or `false`, a number with the number in backticks, text with text in single quotes: matter.fee_basis == 'Fixed fee'.
auto true / false No true adds the block to the plan by itself when includeWhen holds.
variantGroup text No Blocks in the same group are alternatives; a plan takes at most one.
requiresKeys, conflictsKeys lists of keys No Blocks that must be, or can't be, in the same document. Each must be a block in the file.
defines, uses lists of text No Defined terms, so a block that uses a term brings in the block that defines it.
optionalFieldKeys list of field keys No Fields the block merges that may be left unanswered.
unnumbered true / false No true prints the block without a clause number: a heading, a recital, an address.
tags list of text No Labels the Blocks tab's search finds, which also help the AI find the block in a large library.

The block's text and its includeWhen are written differently: a condition inside the text compares text in double quotes ({{ if matter.fee_basis == "Fixed fee" }}), the plan's test in single quotes.

Each placement:

Property Type Required What it holds
documentTypeKey key Yes The document type.
section key Yes The key of one of that type's sections; empty when the type has none.
order number Yes The block's position in the section, lowest first.

Templates

Property Type Required What it holds
documentTypeKey key Yes The document type in the file.
name text Yes The template's name.
description text No What the template is for.
blockKeys list of keys Yes The blocks a document starts with, beside the ones the type always includes.
requireReviewBeforeExport true / false No true holds every export of a document made from the template until someone marks it reviewed.

Selection tests

Property Type Required What it holds
documentTypeKey key Yes The document type in the file.
name text Yes The test's name.
brief text Yes A brief written the way a member would describe the document.
expectedBlockKeys list of keys No The blocks a right answer must pick.
forbiddenBlockKeys list of keys No The blocks a right answer must not pick.

What the import checks

Before it writes anything, the import reads the whole file and refuses it, with a message, when:

  • The zip is larger than 32 MB, or library.json is larger than 16 MB.
  • manifest.json or library/library.json is missing, or either isn't valid JSON.
  • kind isn't library, or formatVersion is higher than 1.
  • library.json holds more than 2,000 blocks or 2,000 fields.
  • There is no document type; a document type, field or block has no valid key, or two share one; a document type has no name, a field no label or no valid kind, a block no title or text, a template no name, or a selection test no name or brief.
  • Its templates would take the workspace past its limit of templates.

Then it lists every field key your workspace already uses, and you choose for each: use the existing field or create a new one. A document type or block key your workspace already uses is given a suffix, engagement-letter-2, and the references to it are updated.

While it writes, a problem refuses the import and removes everything written so far:

  • a document type's numbering, title alignment, list numbering, section heading size, sections or field groups aren't valid, or its header or footer has an error;
  • a field's options, rules, default or image width aren't valid;
  • the import would take the workspace past its limit of document types or building blocks.

A block's text, its includeWhen and the sections its placements name never refuse an import: they are checked when you publish the block. A block whose text has an error arrives marked Errors; the block editor explains each error and you fix it there.

Ignored without a message: a key in mandatoryBlockKeys, requiresKeys, conflictsKeys, a template's blockKeys or a test's block lists that names no block in the file; a placement, template or test that names a document type not in the file; reviewers other than admins and owner; any property the import doesn't recognise.

An import always adds. It never changes a document type or block your workspace already has: importing a changed file into the workspace it came from adds a second copy, with suffixed keys. To change a library in place, edit it on the library page.

A complete example

A short engagement letter: one document type with three sections, seven fields, six blocks (two of them alternatives, added by the answer to one question), a template and a selection test. Save the two files under the names shown, zip them and import the zip.

manifest.json:

json
{
  "formatVersion": 1,
  "kind": "library",
  "name": "Engagement letter (example)",
  "exportedAtUtc": "2026-10-02T09:00:00Z",
  "entityNames": ["Engagement letter (example)", "Standard engagement"],
  "notices": ["An example for learning the drafting library, not suitable for real-world use."]
}

library/library.json:

json
{
  "documentTypes": [
    {
      "key": "engagement-letter",
      "name": "Engagement letter (example)",
      "description": "An example for learning the drafting library, not suitable for real-world use.",
      "sections": [
        { "key": "opening", "title": "Opening", "hideTitle": true },
        { "key": "terms", "title": "Terms of engagement", "numbered": true },
        { "key": "closing", "title": "Closing", "hideTitle": true }
      ],
      "numbering": "continuous",
      "mandatoryBlockKeys": ["opening", "scope", "sign-off"],
      "fieldGroups": [
        { "key": "client", "title": "The client", "fieldKeys": ["client.name", "client.address"] },
        {
          "key": "matter",
          "title": "The matter",
          "fieldKeys": ["matter.description", "matter.start_date", "matter.fee_basis", "matter.fixed_fee"]
        }
      ],
      "selectionGuidance": "Choose the fee block that matches how the brief says the client will be charged.",
      "titlePattern": "Engagement letter for {{ client.name }}",
      "header": "**Example document, not suitable for real-world use**",
      "headerOnFirstPage": true,
      "footer": "{{ firm.name }}, page {{ page.number }} of {{ page.count }}",
      "draftBanner": true,
      "reviewers": ["admins"]
    }
  ],
  "fields": [
    { "key": "firm.name", "label": "Firm name", "kind": "text", "defaultValueJson": "\"Example Firm LLP\"" },
    { "key": "client.name", "label": "Client name", "kind": "text", "documentTypeKeys": ["engagement-letter"] },
    { "key": "client.address", "label": "Client address", "kind": "address", "documentTypeKeys": ["engagement-letter"] },
    {
      "key": "matter.description",
      "label": "What we will advise on",
      "help": "Completes the sentence: We will advise you on…",
      "kind": "textarea",
      "documentTypeKeys": ["engagement-letter"]
    },
    { "key": "matter.start_date", "label": "Start date", "kind": "date", "documentTypeKeys": ["engagement-letter"] },
    {
      "key": "matter.fee_basis",
      "label": "Fee basis",
      "kind": "choice",
      "options": ["Fixed fee", "Hourly rates"],
      "documentTypeKeys": ["engagement-letter"],
      "defaultValueJson": "\"Fixed fee\"",
      "allowUserChange": true
    },
    {
      "key": "matter.fixed_fee",
      "label": "Fixed fee",
      "help": "Before VAT.",
      "kind": "money",
      "validation": { "min": 1 },
      "documentTypeKeys": ["engagement-letter"]
    }
  ],
  "blocks": [
    {
      "key": "opening",
      "title": "Opening",
      "description": "The client's name and address, the greeting and the opening sentence.",
      "useWhen": "Every engagement letter.",
      "script": "{{ client.name }}\n{{ client.address }}\n\nDear {{ client.name }},\n\nThank you for instructing us. This letter sets out the terms on which we will act for you.",
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "opening", "order": 1 }],
      "unnumbered": true
    },
    {
      "key": "scope",
      "title": "Scope of the work",
      "description": "What we will advise on and when the work starts. Defines the Services.",
      "useWhen": "Every engagement letter.",
      "script": "**Scope.** We will advise you on {{ matter.description }} (the \"Services\"), starting on {{ matter.start_date | format_date \"d MMMM yyyy\" }}.",
      "defines": ["Services"],
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "terms", "order": 1 }]
    },
    {
      "key": "fees-fixed",
      "title": "Fees: a fixed fee",
      "description": "A fixed fee for the Services, plus VAT.",
      "useWhen": "The client pays a fixed fee, not by the hour.",
      "script": "**Fees.** Our fee for the Services is a fixed {{ matter.fixed_fee | money }} plus VAT.",
      "includeWhen": "matter.fee_basis == 'Fixed fee'",
      "auto": true,
      "variantGroup": "fees",
      "uses": ["Services"],
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "terms", "order": 2 }]
    },
    {
      "key": "fees-hourly",
      "title": "Fees: hourly rates",
      "description": "Fees charged for the time spent, at hourly rates confirmed in writing.",
      "useWhen": "The client is charged by the hour, not a fixed fee.",
      "script": "**Fees.** We charge for the time we spend on the Services at our hourly rates, which we will confirm to you in writing.",
      "includeWhen": "matter.fee_basis == 'Hourly rates'",
      "auto": true,
      "variantGroup": "fees",
      "uses": ["Services"],
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "terms", "order": 3 }]
    },
    {
      "key": "complaints",
      "title": "Complaints",
      "description": "How the client can complain about the Services.",
      "useWhen": "The letter should tell the client how to complain, as most should.",
      "script": "**Complaints.** If you are unhappy with the Services described in clause {{ ref \"scope\" }}, please tell us and we will try to put things right.",
      "uses": ["Services"],
      "tags": ["client care"],
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "terms", "order": 4 }]
    },
    {
      "key": "sign-off",
      "title": "Sign-off",
      "description": "The closing and the firm's name.",
      "useWhen": "Every engagement letter.",
      "script": "Yours sincerely,\n\n{{ firm.name }}",
      "placements": [{ "documentTypeKey": "engagement-letter", "section": "closing", "order": 1 }],
      "unnumbered": true
    }
  ],
  "templates": [
    {
      "documentTypeKey": "engagement-letter",
      "name": "Standard engagement",
      "description": "With the complaints clause.",
      "blockKeys": ["complaints"]
    }
  ],
  "selectionTests": [
    {
      "documentTypeKey": "engagement-letter",
      "name": "Hourly rates",
      "brief": "New client Jane Smith of 1 High Street, Bath, about a boundary dispute with her neighbour. She will be charged at our hourly rates.",
      "expectedBlockKeys": ["fees-hourly"],
      "forbiddenBlockKeys": ["fees-fixed"]
    }
  ]
}

What the example shows:

  • Firm name has no documentTypeKeys, so every document type can use it, and a default with no allowUserChange, so it is fixed. If your workspace already has a firm.name field (the example libraries bring one), the import asks you to choose: use the existing field.
  • Fees: a fixed fee and Fees: hourly rates are alternatives in one variantGroup. Each has auto and an includeWhen, so the answer to Fee basis adds one of them, and the form asks Fixed fee only when the fixed-fee block is in the plan.
  • Scope of the work defines Services, which three blocks use. Complaints refers to the scope clause by its key: {{ ref "scope" }} prints its number.
  • After the import, open each of the six blocks on the Blocks tab and choose Publish. The template and the selection test are ready once the blocks are published.

Build one with an AI assistant

A general-purpose AI assistant can turn a document your firm already uses into a first draft of a library file: the fields from the facts that change between documents, a block for each clause, alternatives as variant groups. You review the result in the library like any other import.

Important

Your precedents may be confidential. Use only an assistant your organisation permits for that material: what you give an outside assistant leaves your workspace. To write or improve one clause at a time, the block editor's AI drafting runs on your workspace's own model infrastructure, and nothing is sent to a third party.

  1. Gather what the assistant needs: the document you are starting from, the schema from this page, the block language reference, and a complete file to copy the style from, such as the example above.

  2. Ask for library/library.json, saying what to turn into what. For example:

    text
    Turn the attached precedent into a drafting library file, library/library.json,
    valid against the attached JSON Schema. The block language is described in the
    attached reference, and the attached example shows the style.
    - One document type, key "engagement-letter", with the precedent's headings as
      sections, in order.
    - A field for every fact that changes from one document to the next, keyed
      group.fact (client.name, matter.start_date), with the kind that fits the answer.
    - A block for each clause, its text in the block language, merging the fields
      with {{ field.key }}. Keep the precedent's wording.
    - Where the precedent offers alternatives, a block for each alternative in one
      variantGroup, with an includeWhen over the field that decides between them.
    - For every block, a description and a useWhen written for a colleague who has
      never seen the precedent, and a placement naming one of the type's sections.
    - Put any fact you are unsure of in square brackets in the text, and list your
      assumptions after the file.
    
  3. For a long document, ask for the fields first, then the blocks a section at a time, and put the lists together into one file.

  4. Check the result against the schema. Many code editors check a JSON file against a schema as you type once you point them at it, and mark a misspelt property or an unknown value.

  5. Write manifest.json, make the zip and import it. Read the notes the import shows.

  6. On the Blocks tab, open each block. The editor lists any error in its text with a fix where the fix is mechanical; correct each block against your precedent and Publish it.

  7. Run the selection tests once the blocks are published, then widen Who can use on the document type and its templates.

Other ways to use the file

  • Start from an example. Unzip an example library, change it, zip it again and import it: a quick way to set up a library shaped like one you know.
  • Copy a library to another workspace. Export the document type on its card, and import the zip in the other workspace.
  • Make the same change in many blocks. Export, edit the file (rename a defined term in every block, say) and give the document type a new name, then import it, check it and archive the old type.
  • Keep the source. A library file is a readable record of a library at a moment. Keep exports beside your precedents, and compare two to see what changed.

Make the zip

Zip the two items, manifest.json and the library folder, not a folder that holds them: the import looks for manifest.json at the top of the zip.

  • Windows: select both, right-click and choose Compress to ZIP file (on earlier versions, Send to → Compressed (zipped) folder).

  • macOS: select both, Control-click and choose Compress 2 Items.

  • PowerShell 7, in the folder that holds them:

    powershell
    Compress-Archive -Path manifest.json, library -DestinationPath engagement-letter.zip
    

    Use PowerShell 7 rather than Windows PowerShell 5.1, which writes the folder name in a way the import can't read.

  • Terminal on macOS or Linux, in the same folder:

    bash
    zip -r engagement-letter.zip manifest.json library
    

If the import is refused

The message says What to do
The file is not a Ferrith bundle — it has no manifest. manifest.json isn't at the top of the zip. Zip the two items, not the folder that holds them.
The bundle has no library. library.json isn't at library/library.json. Check the folder's name, and zip it with one of the methods above.
The bundle contains an entry that is not valid JSON. A comma, quotation mark or bracket is missing or extra. Check the file against the schema.
This is not a library bundle. kind isn't library.
This bundle was exported by a newer version of Ferrith than this workspace runs. Set formatVersion to 1.
A field in the bundle has no valid key, label or kind. Check the field's key against the shape above, and the spelling of its kind.
Pick one of the numbering styles. numbering is missing or misspelt.
Pick a heading size from 1 to 4. sectionHeadingLevel is outside 1 to 4. Leave it out for the default, 2.
The default isn't a valid value for this field, or doesn't pass the field's own rules Correct the field's defaultValueJson, or its rules: see the default answers above.
The header (or footer) has an error at line… Correct the header or footer; the rest of the message names the error.
That file is larger than the 32 MB library bundle limit. Remove what the library doesn't need, or split it into two document types.
This workspace is limited to … templates and cannot fit the library's … Delete templates you no longer need or ask support to raise the limit, or take some templates out of the file.
The field … isn't in this library. A field group names a field that isn't in the file.

The schema

Two JSON Schemas (draft 2020-12), one for each file. A file that passes both has the shape the import reads; the checks the schema can't express, such as a valid default answer, are in what the import checks.

manifest.json:

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Ferrith drafting library file: manifest.json",
  "type": "object",
  "required": ["formatVersion", "kind", "name"],
  "additionalProperties": false,
  "properties": {
    "formatVersion": { "const": 1, "description": "The file format version." },
    "kind": { "const": "library", "description": "What the file holds." },
    "name": { "type": "string", "minLength": 1, "description": "The library's name, shown when it is imported." },
    "exportedAtUtc": { "type": "string", "format": "date-time", "description": "When the file was written, in UTC." },
    "entityNames": {
      "type": "array",
      "items": { "type": "string" },
      "description": "The names of the document type and its templates."
    },
    "notices": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Notes shown to the person importing the file."
    }
  }
}

library/library.json:

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Ferrith drafting library file: library/library.json",
  "description": "One document type with everything it stands on: its fields, building blocks, templates and selection tests, each referring to the others by key. A property left out takes its default: an empty list, null or false.",
  "type": "object",
  "required": ["documentTypes"],
  "additionalProperties": false,
  "properties": {
    "documentTypes": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/documentType" },
      "description": "The document type, as a list of one."
    },
    "fields": {
      "type": "array",
      "maxItems": 2000,
      "items": { "$ref": "#/$defs/field" },
      "description": "The questions the type's documents ask."
    },
    "blocks": {
      "type": "array",
      "maxItems": 2000,
      "items": { "$ref": "#/$defs/block" },
      "description": "The building blocks."
    },
    "templates": {
      "type": "array",
      "items": { "$ref": "#/$defs/template" },
      "description": "Fixed recipes of blocks for the type."
    },
    "selectionTests": {
      "type": "array",
      "items": { "$ref": "#/$defs/selectionTest" },
      "description": "Briefs that measure how well the AI picks blocks."
    }
  },
  "$defs": {
    "key": {
      "type": "string",
      "maxLength": 64,
      "pattern": "^[a-z0-9][a-z0-9-]*$",
      "description": "The key of a document type, section, field group or block: lower-case letters, digits and hyphens."
    },
    "fieldKey": {
      "type": "string",
      "maxLength": 64,
      "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){0,5}$",
      "not": {
        "pattern": "^(document|page|money|words|ordinal|format_date|names|list|ref|image|anchor|page_break|string|array|math|date|object|regex|timespan|html|if|else|end|for|in|while|case|when|func|ret|with|wrap|capture|include|import|readonly|true|false|null|empty|tablerow|break|continue|this|loop)(\\.|$)"
      },
      "description": "A field key: lower-case words joined by dots and underscores, at most six words. The first word can't be one of the block language's own names."
    },
    "keyList": { "type": "array", "items": { "$ref": "#/$defs/key" }, "uniqueItems": true },
    "fieldKeyList": { "type": "array", "items": { "$ref": "#/$defs/fieldKey" }, "uniqueItems": true },
    "textList": { "type": "array", "items": { "type": "string" } },
    "documentType": {
      "type": "object",
      "required": ["key", "name", "numbering"],
      "additionalProperties": false,
      "properties": {
        "key": { "$ref": "#/$defs/key" },
        "name": { "type": "string", "minLength": 1, "description": "The name members see." },
        "description": { "type": ["string", "null"] },
        "sections": {
          "type": "array",
          "items": { "$ref": "#/$defs/section" },
          "description": "The headings, in order. A part comes after the schedule it belongs to."
        },
        "numbering": {
          "enum": ["continuous", "by_section", "none"],
          "description": "continuous: 1, 2, 3 through the document. by_section: 3.1, 3.2, restarting in each section. none: no clause numbers."
        },
        "numberSectionHeadings": { "type": "boolean", "description": "With numbering by_section, put a numbered section's number in its heading." },
        "sectionHeadingLevel": {
          "anyOf": [{ "type": "integer", "minimum": 1, "maximum": 4 }, { "type": "null" }],
          "description": "How large the section headings print, 1 (the title's size) to 4; parts one size smaller. Null or left out: 2."
        },
        "listNumbering": {
          "enum": ["plain", "legal", null],
          "description": "plain: a block's numbered lists print as written. legal: they print as sub-paragraphs under the clause, 4.7.1 then (a) then (i)."
        },
        "mandatoryBlockKeys": {
          "$ref": "#/$defs/keyList",
          "description": "The blocks every document of the type includes."
        },
        "fieldGroups": {
          "type": "array",
          "items": { "$ref": "#/$defs/fieldGroup" },
          "description": "The headings the form asks its questions under."
        },
        "selectionGuidance": {
          "type": ["string", "null"],
          "description": "Guidance for the AI when it proposes blocks for the type."
        },
        "titlePattern": {
          "type": ["string", "null"],
          "description": "The document title, with merge fields: Engagement letter for {{ client.name }}."
        },
        "draftBanner": { "type": "boolean", "description": "Mark the text as a draft until the document is reviewed." },
        "requireReviewBeforeExport": { "type": "boolean", "description": "Hold every export until the document is reviewed." },
        "hideTitle": { "type": "boolean", "description": "Leave the title line out of the document." },
        "titleAlignment": { "enum": ["left", "centre", "right", null] },
        "header": {
          "type": ["string", "null"],
          "description": "Printed at the top of every page, in the block language. page.number and page.count print the page numbers; ref can't be used."
        },
        "footer": {
          "type": ["string", "null"],
          "description": "Printed at the foot of every page, written like the header."
        },
        "headerOnFirstPage": { "type": "boolean", "description": "Print the header on the first page too." },
        "reviewers": {
          "type": ["array", "null"],
          "items": { "enum": ["admins", "owner"] },
          "uniqueItems": true,
          "description": "Who may review a document of the type. Named people and roles can't travel in a file."
        }
      }
    },
    "section": {
      "type": "object",
      "required": ["key", "title"],
      "additionalProperties": false,
      "properties": {
        "key": { "$ref": "#/$defs/key" },
        "title": { "type": "string", "minLength": 1, "description": "The heading." },
        "numbered": { "type": "boolean", "description": "Number the clauses in the section." },
        "hideTitle": { "type": "boolean", "description": "Leave the heading out; the section's blocks supply their own." },
        "kind": {
          "enum": ["clause", "schedule", "part", null],
          "description": "clause: a section of the body. schedule: labelled Schedule 1, 2 as it prints. part: a part of a schedule."
        },
        "parentKey": {
          "anyOf": [{ "$ref": "#/$defs/key" }, { "type": "null" }],
          "description": "For a part, the key of its schedule, listed above it."
        },
        "newPage": { "type": "boolean", "description": "Start the section on a new page in the PDF and Word exports." }
      }
    },
    "fieldGroup": {
      "type": "object",
      "required": ["key", "title", "fieldKeys"],
      "additionalProperties": false,
      "properties": {
        "key": { "$ref": "#/$defs/key" },
        "title": { "type": "string", "minLength": 1, "description": "The heading on the form." },
        "fieldKeys": {
          "$ref": "#/$defs/fieldKeyList",
          "description": "The questions under the heading, in order. A field can be in one group only."
        }
      }
    },
    "field": {
      "type": "object",
      "required": ["key", "label", "kind"],
      "additionalProperties": false,
      "properties": {
        "key": { "$ref": "#/$defs/fieldKey" },
        "label": { "type": "string", "minLength": 1, "description": "The question, as the form shows it." },
        "help": { "type": ["string", "null"], "description": "A line of help under the question." },
        "kind": {
          "enum": ["text", "textarea", "date", "integer", "number", "money", "boolean", "choice", "choices", "person", "persons", "address", "image"],
          "description": "textarea is long text, integer a whole number, boolean yes / no, choice one of a list, choices several from a list, persons people."
        },
        "options": {
          "type": "array",
          "maxItems": 200,
          "uniqueItems": true,
          "items": { "type": "string", "minLength": 1 },
          "description": "The answers to choose from, for choice and choices."
        },
        "validation": { "anyOf": [{ "$ref": "#/$defs/validation" }, { "type": "null" }] },
        "documentTypeKeys": {
          "$ref": "#/$defs/keyList",
          "description": "The document types the field belongs to. Empty: every document type in the workspace."
        },
        "defaultValueJson": {
          "type": ["string", "null"],
          "description": "A default answer, written as JSON in a string: \"\\\"Bath\\\"\" for text, \"\\\"2026-10-01\\\"\" for a date, \"12\" for a number, \"true\" for yes / no, \"[\\\"Email\\\"]\" for several from a list, \"{\\\"full_name\\\": \\\"Jane Smith\\\", \\\"address\\\": \\\"1 High Street\\\"}\" for a person. Always null for an image."
        },
        "allowUserChange": {
          "type": "boolean",
          "description": "With a default: true lets the person drafting change it; false makes it fixed."
        },
        "imageWidthMm": {
          "type": ["integer", "null"],
          "minimum": 5,
          "maximum": 170,
          "description": "For an image, the printed width in millimetres. Null: 40."
        }
      },
      "allOf": [
        {
          "if": { "properties": { "kind": { "enum": ["choice", "choices"] } } },
          "then": { "required": ["options"], "properties": { "options": { "minItems": 1 } } }
        },
        {
          "if": { "properties": { "kind": { "const": "image" } } },
          "then": { "properties": { "defaultValueJson": { "type": "null" } } }
        }
      ]
    },
    "validation": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "minLength": { "type": ["integer", "null"], "minimum": 0, "description": "Text, long text, address: the shortest answer." },
        "maxLength": { "type": ["integer", "null"], "minimum": 0, "description": "Text, long text, address: the longest answer." },
        "min": { "type": ["number", "null"], "description": "Whole number, number, money: the lowest answer." },
        "max": { "type": ["number", "null"], "description": "Whole number, number, money: the highest answer." },
        "pattern": { "type": ["string", "null"], "description": "Text, long text, address: a regular expression the answer must match." },
        "minItems": { "type": ["integer", "null"], "minimum": 0, "description": "Several from a list, people: the fewest answers." },
        "maxItems": { "type": ["integer", "null"], "minimum": 0, "description": "Several from a list, people: the most answers." }
      }
    },
    "block": {
      "type": "object",
      "required": ["key", "title", "script"],
      "additionalProperties": false,
      "properties": {
        "key": { "$ref": "#/$defs/key" },
        "title": { "type": "string", "minLength": 1 },
        "description": {
          "type": ["string", "null"],
          "description": "What the block says. The AI reads it, with useWhen, when it proposes blocks."
        },
        "useWhen": { "type": ["string", "null"], "description": "When to choose the block, and over which alternative." },
        "script": {
          "type": "string",
          "minLength": 1,
          "description": "The block's text in the block language: Markdown, with merge fields, conditions and repeats between double braces. A new line is \\n."
        },
        "guidance": { "type": ["string", "null"], "description": "Guidance for the person drafting." },
        "includeWhen": {
          "type": ["string", "null"],
          "description": "The plan's test over the answers: a yes / no compares with `true` or `false`, a number with the number in backticks, text with text in single quotes, as in matter.fee_basis == 'Fixed fee'."
        },
        "defines": { "$ref": "#/$defs/textList", "description": "The defined terms the block defines." },
        "uses": { "$ref": "#/$defs/textList", "description": "The defined terms the block uses." },
        "placements": {
          "type": "array",
          "items": { "$ref": "#/$defs/placement" },
          "description": "Where the block sits in each document type. A block needs one to be published."
        },
        "tags": { "$ref": "#/$defs/textList", "description": "Labels for finding the block." },
        "optionalFieldKeys": {
          "$ref": "#/$defs/fieldKeyList",
          "description": "Fields the block merges that may be left unanswered."
        },
        "requiresKeys": { "$ref": "#/$defs/keyList", "description": "Blocks that must be in the same document." },
        "conflictsKeys": { "$ref": "#/$defs/keyList", "description": "Blocks that can't be in the same document." },
        "variantGroup": {
          "type": ["string", "null"],
          "description": "Blocks in the same group are alternatives; a plan takes at most one."
        },
        "auto": { "type": "boolean", "description": "Add the block to the plan by itself when includeWhen holds." },
        "unnumbered": { "type": "boolean", "description": "Print the block without a clause number." }
      }
    },
    "placement": {
      "type": "object",
      "required": ["documentTypeKey", "section", "order"],
      "additionalProperties": false,
      "properties": {
        "documentTypeKey": { "$ref": "#/$defs/key" },
        "section": {
          "type": "string",
          "pattern": "^$|^[a-z0-9][a-z0-9-]*$",
          "description": "The key of one of the type's sections; empty when the type has none."
        },
        "order": { "type": "integer", "description": "The block's position in the section, lowest first." }
      }
    },
    "template": {
      "type": "object",
      "required": ["documentTypeKey", "name", "blockKeys"],
      "additionalProperties": false,
      "properties": {
        "documentTypeKey": { "$ref": "#/$defs/key" },
        "name": { "type": "string", "minLength": 1 },
        "description": { "type": ["string", "null"] },
        "blockKeys": {
          "$ref": "#/$defs/keyList",
          "description": "The blocks a document starts with, beside the ones the type always includes."
        },
        "requireReviewBeforeExport": { "type": "boolean" }
      }
    },
    "selectionTest": {
      "type": "object",
      "required": ["documentTypeKey", "name", "brief"],
      "additionalProperties": false,
      "properties": {
        "documentTypeKey": { "$ref": "#/$defs/key" },
        "name": { "type": "string", "minLength": 1 },
        "brief": { "type": "string", "minLength": 1, "description": "Written the way a member would describe the document." },
        "expectedBlockKeys": { "$ref": "#/$defs/keyList", "description": "Blocks a right answer must pick." },
        "forbiddenBlockKeys": { "$ref": "#/$defs/keyList", "description": "Blocks a right answer must not pick." }
      }
    }
  }
}

Related: the drafting library, where the imported document type, fields and blocks are maintained.