Lake format
The exact layout and file formats of a lake repository (documents, folder order in `_folder.yaml`, media sidecars, the manifest) and of the `lake.lock` file in a site. For developers and anyone who reads or writes the lake without the CMS.
On this page
- What the repository holds
- Identity: ULIDs, never paths
- Documents
- Frontmatter
- Body
- Folders and file names
- Order and folder files
- The sorting rule
- Who sets the numbers
- Media sidecars
- Images
- Documents
- Fields
- The text of a document file
- The manifest
- How the builder reads the lake
- lake.lock
- Writing a lake by hand
- What’s next
The lake is a Git repository of plain files. The CMS writes it when you publish, and sites read it. Because the format is just Markdown, JSON and the folders between them, you can also read a lake with any tool, import one that you wrote by hand, or check what the CMS has written. This page is the full specification of what is in the repository.
For the idea behind the lake, see What is mdlake.
What the repository holds
lake-acme/
_manifest.json
About/
about.md
Services/
_folder.yaml
process-automation.md
custom-business-systems.md
Cases/
regional-distributor.md
assets/
01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg
01JBXQAP4V9W5X1Y3A8B2C7D6H.json
01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf
01JBXQ0A9C3E5G7H1K4M6P8R2T.json
01JBXQ0A9C3E5G7H1K4M6P8R2T.md
_meta/| Path | What it is |
|---|---|
<folder>/<name>.md | A published document. The folders are the editors’ folders. |
<folder>/_folder.yaml | Optional. The folder’s own place among the items of its parent folder. See Order and folder files. |
assets/<id>.<ext> | A published media file (an image or a document such as a PDF). |
assets/<id>.json | The sidecar of that file: its facts and the editors’ metadata. |
assets/<id>.md | For document files only: the extracted text, so that tools and AI can read it. |
_manifest.json | An index of everything published. Rewritten on every publish. |
_meta/ | Reserved for metadata. The loader skips it. The example lake has _meta/tags.yaml, and the builder’s check-lake skill reads _meta/requirements/ when it exists. |
Only published content is in the lake. Drafts live in the CMS’s database, not in Git. Unpublishing removes the file.
Identity: ULIDs, never paths
Every document and every media file has an ID, a ULID: 26 characters from Crockford’s base32 (digits and capital letters without I, L, O and U), for example 01JBXQ4G8N3P9Q5R7T2V6W1X0B. IDs sort by creation time.
The ID is how things are found and linked. A path is only where the file happens to be: editors rename and move documents, and the ID stays. Sites bind to IDs for this reason, never to paths. See How sites work.
Documents
A document is a Markdown file with YAML frontmatter. The CMS writes the keys in this order, with order after updated and only when the document has a number:
---
id: 01JBXQ4G8N3P9Q5R7T2V6W1X0B
title: Process automation
summary: We map how work really flows through your company and automate the repetitive parts.
tags: [service]
status: published
slug: process-automation
created: 2026-03-02T09:30:00Z
updated: 2026-08-20T10:00:00Z
order: 10
---
Most companies carry a handful of processes that eat hours every week.
## What you get
- A process map that shows where time and errors accumulateFrontmatter
| Key | Required | Rule |
|---|---|---|
id | yes | A ULID. |
title | yes | Not empty. |
summary | no (defaults to empty) | Plain text, one paragraph, at most 300 characters. |
tags | no | Up to 20 tags, each at most 50 characters. |
status | yes | draft or published. In the lake it is published. |
slug | no | Lowercase words separated by single hyphens (^[a-z0-9]+(?:-[a-z0-9]+)*$), up to 100 characters. It becomes the last part of the page address on a site. |
template | no | A free-text template name. |
created, updated | yes | UTC timestamps in the form 2026-03-02T09:30:00Z. |
order | no | A number. Lower comes first among the items of the document’s own folder. The CMS owns it: editors set it by dragging in the tree, and it is written as a whole number. See Order and folder files. A value that is not a number is reported as an issue, never ignored. |
Any other key is kept as it is. For example, image can hold an asset ID, and a site’s template can read it. The CMS refuses an extra key with the name of a contract key (id, title, summary, tags, status, slug, template, created, updated, order).
The frontmatter is YAML only. A file that tries to run code (---js) is refused by the loader. Anchors, aliases and custom tags in the YAML are not interpreted.
Body
The body is CommonMark Markdown with the subset the editor supports. Two kinds of links connect the lake:
| Link | Meaning |
|---|---|
lake://<ID> | Another document. Add #heading-anchor to point at a section: lake://01JBXQ6J0Q5R1S7T9W4X8Y3Z2D#results. |
asset://<ID> | A media file, in an image () or a link. |
The links stay in the Markdown. A site turns them into addresses when it builds, and the editor turns them into previews for display.
Folders and file names
| Part | Rule |
|---|---|
| Folder name | Letters, digits, spaces, hyphens and underscores; at most 64 characters; it cannot start with _ or .. |
| Depth | At most 8 levels; the whole path at most 400 characters. |
| Reserved top-level names | assets and _meta, in any capitalisation. |
| File name | Letters, digits, hyphens and underscores, starting with a letter or digit; at most 100 characters, plus .md. |
Order and folder files
Every item in a folder, a document or a subfolder, can have its own order number. The number only means something among the items of the folder it is in. A document keeps its number in its frontmatter. A folder keeps its number in a small file inside it:
order: 20That file is <folder>/_folder.yaml, for example Services/_folder.yaml. It positions the folder Services among the items of its parent, which here is the top level.
| Rule | Detail |
|---|---|
| Where | Inside the folder it describes. It is YAML and not Markdown, so nothing mistakes it for a document. |
order | A number. Lower comes first. The CMS writes whole numbers, 10 apart. |
| Other keys | Kept. When the CMS changes the number, the other keys stay in the file. |
| When it exists | Only while the folder has a number and published content. The CMS adds it when the first document in a numbered folder is published and removes it when the last one goes away. A folder that holds only _folder.yaml is never written. |
| At the top | A _folder.yaml at the root of the lake does nothing, because the root has no parent. The loader reports it as an issue. |
| Not read | A _folder.yaml in _meta/ or in the root assets/ folder. |
The sorting rule
One rule sorts the items of one folder, documents and subfolders mixed:
- Items with an
ordercome first, lowest number first. - Then the items without one, by title (a document) or name (a folder), in one sequence.
- Items with the same number, or the same title or name, are sorted by title or name, ignoring case and reading numbers as numbers (
Part 2beforePart 10).
A folder never takes a number from the documents inside it, and a document never takes one from outside its folder. A folder without its own number sorts after the numbered items, even when every page inside it has one.
Who sets the numbers
In a workspace the CMS owns order. Editors reorder in the tree (Reorder documents and folders), and the CMS works out the numbers and writes them to the lake. A document in the CMS holds its number next to its folder, not in its draft, so reordering never makes a document “changed”. When you write a lake by hand, you write the numbers yourself. Leaving gaps (10, 20, 30) makes room to insert.
The read API’s /manifest carries the folder numbers as folders: [{ path, order }], for numbered folders that have published content (Workspace tokens). The _manifest.json file in the lake does not change: it has no folder numbers.
Media sidecars
Each published media file has a JSON file next to it with the same ID. The sidecar is what a site reads. The CMS writes it; sites and tools only read it.
Images
{
"id": "01JBXQAP4V9W5X1Y3A8B2C7D6H",
"kind": "image",
"file": "assets/01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg",
"url": "https://acme.blob.core.windows.net/public-assets/01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg",
"mime": "image/jpeg",
"width": 1600,
"height": 1067,
"alt": "Warm abstract shapes in terracotta and sand",
"caption": "",
"focal": { "x": 0.5, "y": 0.35 },
"crop": true,
"rights": "Own material",
"tags": []
}Documents
{
"id": "01JBXQ0A9C3E5G7H1K4M6P8R2T",
"kind": "document",
"file": "assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf",
"url": "https://acme.blob.core.windows.net/public-assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf",
"mime": "application/pdf",
"title": "Annual report 2025",
"bytes": 482113,
"pages": 24,
"rights": "",
"tags": ["report"]
}Fields
| Field | Kinds | Meaning |
|---|---|---|
id | both | The ULID. |
kind | both | image or document. |
file | both | The repository path of the file, assets/<id>.<extension>. |
url | both, optional | The public address the file is served from (the CMS’s public container, never a signed or private link). The CMS writes it. It is missing in sidecars published before the CMS wrote it, in unpublished previews and in the example lake. Sites download assets from here, or from PUBLIC_ASSET_BASE_URL when that is set. |
mime | both | The media type. |
width, height | image | Pixels. |
alt | image | The alternative text. Empty when nobody wrote one; a site should treat that as missing content. |
caption | image | A caption. |
focal | image | The focal point as fractions of the image: x and y from 0 to 1, where (0, 0) is the top left. Default (0.5, 0.5). Sites keep it in view when they crop. |
crop | image | true by default. false for images with text or a logo: a site must show them whole, never cropped. |
title | document | The title. |
bytes | document | The size. |
pages | document | The page count, when known. |
rights | both | Who owns it or on what terms it may be used. Free text. |
tags | both | The same tag rules as documents. |
Other keys are kept. The loader validates each sidecar against these rules and reports a broken one as an issue.
The text of a document file
For a document file the CMS also writes assets/<id>.md:
---
id: "01JBXQ0A9C3E5G7H1K4M6P8R2T"
title: "Annual report 2025"
kind: document
file: "assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf"
mime: "application/pdf"
rights: ""
tags: ["report"]
---
The extracted text of the file, as Markdown.It has no status, created or updated, so it is not a document in the sense of the previous section. The loader never reads the assets/ folder at the root of the lake as documents (since builder 0.19.1), so a sidecar like this one is not reported as an issue and does not fail a build. The CMS reserves the folder name assets (in any case) for documents, so no document lives there. A folder with that name deeper in the tree, such as Docs/assets/, is read as usual. With an older kit, a published PDF made every build fail on this file.
The manifest
_manifest.json lists every published document and the media that must always be available. It is rewritten at every publish. It is deterministic: entries are sorted by path and there is no timestamp of its own, so a publish that changes nothing changes nothing in the file.
{
"version": 1,
"documents": [
{
"id": "01JBXQ4G8N3P9Q5R7T2V6W1X0B",
"path": "Services/process-automation.md",
"title": "Process automation",
"summary": "We map how work really flows through your company.",
"tags": ["service"],
"slug": "process-automation",
"links": ["01JBXQ6J0Q5R1S7T9W4X8Y3Z2D"],
"assets": ["01JBXQAP4V9W5X1Y3A8B2C7D6H"],
"updated": "2026-08-20T10:00:00Z"
}
],
"assets": [
{ "id": "01JBXQCR6X1Y7Z3A5C0D4E9F8K", "path": "assets/01JBXQCR6X1Y7Z3A5C0D4E9F8K.json", "kind": "image", "title": "Logo" }
]
}| Field | Meaning |
|---|---|
version | Always 1. |
documents[].id, path, title, summary, tags, updated | Copied from the document. |
documents[].slug | Left out when the document has none. |
documents[].links | The IDs of the lake:// links in the body, in order of first appearance. |
documents[].assets | The IDs of the media the document needs: asset:// links in the body, asset:// links in extra frontmatter strings, and a top-level image key that holds an asset ID. |
assets[] | Media that is always included (for example brand assets): id, the path of its sidecar, kind and title. The key is left out when there are none. |
A tool that needs an overview of the lake reads the manifest first and opens documents only when it needs the body. The builder’s check-lake skill does this.
How the builder reads the lake
The loader (@mdlake/lake-loader) reads a lake folder as follows:
- It reads every
.mdfile outside_meta,.gitandnode_modules, and every_folder.yamloutside them. - It validates the frontmatter with Zod. A file that is invalid, cannot be parsed, or repeats an ID is reported as an issue, with its path and the reason. Issues are never skipped silently, and
npm run checkfails on them. - It validates each
_folder.yamlthe same way. A file that is not valid YAML or not a mapping, anorderthat is not a finite number, and a file at the root of the lake are issues. The numbered folders are available to sites aslake.folders, with the path from the lake root. - It keeps drafts out, except in a preview.
- It reads every JSON sidecar outside
_metaand validates it.
Sites bind to the lake in lake.config.ts by ID for single documents and by query for collections:
| Query key | Meaning |
|---|---|
folder | A folder relative to the lake root, including its subfolders. |
tags | The document must have every listed tag. |
sort | title (default), manual, order, updated-desc or updated-asc. order sorts by the documents’ order, then by title. Folders play no part in a collection. |
order | A list of document IDs in display order, for sort: manual. Documents not listed follow, by title. |
updated-desc and updated-asc change the order whenever someone edits a document. Use them for news and blogs only.
lake.lock
A site records which commit of the lake it is built from in lake.lock, at the site’s root. It is JSON:
{
"lake": {
"workspace": "acme",
"remote": "https://github.com/acme-org/lake-acme.git",
"commit": "9b1c0e2d7f4a6c3b8e5d1a0f2c4b6d8e9a7c5b3d",
"lockedAt": "2026-10-20T09:14:00Z"
},
"mode": "snapshot"
}| Field | Meaning |
|---|---|
lake.workspace | The workspace slug. |
lake.remote | The Git address of the lake. Optional. Never contains credentials: an address with a user or token is refused. Without it, the site needs LAKE_PATH for every build. |
lake.commit | The commit the site is built from. It must be the full 40-character hash in lower-case hexadecimal, because the kit fetches it by hash. An abbreviation makes lake.lock invalid. SHA-256 repositories are not supported; the CMS creates SHA-1 repositories. |
lake.lockedAt | When it was locked. |
mode | snapshot: build only from the locked commit, and move it with the refresh flow. live: follow the latest commit, and do not check. |
The refresh flow moves commit and lockedAt and nothing else. A tool writes this file, not you. See Refresh content and The lake in CI, and caches.
Writing a lake by hand
You can build a lake without the CMS, for tests or for a first import:
- Create the folders and
.mdfiles with the frontmatter above. Generate IDs with any ULID tool. The Markdown folder import in the CMS can bring such files in and gives them IDs where they have none. No frontmatter is required: the title comes from the frontmatter, else from the first level-1 heading, else from the file name. Anidin the frontmatter is kept when it is a valid ULID that is free. See Importing content. Fields the CMS manages, such asstatus,createdandupdated, are not taken from the file; you set them when you write a lake without the CMS, as in the next step. - Set
status: publishedand both timestamps. To order the documents, addordernumbers, and putorder: 20in a_folder.yamlinside each folder you want to place. - For media, put the file and its sidecar in
assets/, with the same ID. Usekind,file,mimeand, for images,widthandheight. - Run the site’s
npm run check:bindings. Every problem in the lake is listed as an issue with its path.
A lake that you write by hand has no _manifest.json until the CMS publishes. The loader does not need it. The builder’s skills use it when it exists.
What’s next
- CMS configuration for the settings that decide where the lake lives.
- Site configuration for
lake.config.tsandlake.lockin the site. - Glossary.
