Board formats
A board is a directory of cards. The complete card schema is documented below. The write commands and the agent protocol are built for this format.
Cardfile board
Point source: at a directory and the board becomes a cardfile board, the format built for boards that agents and humans operate together through git:
kanban:
source: board # a directory, not a fileThe board directory contains the column set in board.yaml (human-owned, changes rarely), an in-repo copy of the session protocol in SKILL.md, and the card files under cards/. Files starting with _ are not cards.
Boards on another branch
Set ref when the board has an independent Git history. Folio reads that
branch through a managed detached worktree under .worktrees/; it never
merges the planning branch into the code checkout. Use source for one board
or sources for named, independent canvases:
kanban:
ref: board
sources:
docs: folio-docs/board
agents: folio-agents/boardEvery source remains a normal cardfile board with its own board.yaml, WIP
limits, cards, and release cycle. If sources share a column id, its title and
limit must match. Card ids must be unique across the projection.
board.yaml
board.yaml holds the column set, and optionally an icon per tag:
title: "Folio development"
icons: # optional; tag -> icon, worn by every card with the tag
core: "⚙️"
columns:
- id: backlog # optional; defaults to the slugified title
title: Backlog
- title: In progress
limit: 3 # optional WIP limit; must be a positive integer
- title: Donetitleis optional; the optional browser adapter’skanban.titleoverrides it when both are set.- Each column needs a
title.iddefaults to the slugified title. Duplicate column ids fail the build. limitmust be a positive integer; an invalid value is warned about and ignored (no limit).iconsmaps a tag to an icon (an emoji string); a card wears the icon of its first mapped tag, on its face and in its dialog. A malformed map warns and renders nothing.- Column order in
board.yamlis column order on the board. Column order is also meaning: the last column is where blockers count as resolved. Unknown keys are silently ignored.
There is no index of cards anywhere. Column membership is derived per card from its status frontmatter, which is what makes cross-card merge conflicts structurally impossible.
Card files
Every card is one Markdown file in cards/. The filename stem is the card id: a permanent lowercase slug (ship-browser-canvas.md is card ship-browser-canvas, forever). A filename that is not already a slug fails validation. Files whose name starts with _ or . are ignored (so _TEMPLATE.md, editor lock files, and other stray editor files like .goutputstream-* never break a check), as is anything that is not .md.
Machine state lives in the frontmatter, prose in the body:
---
title: "Ship browser canvas"
status: in-progress # a column id; this IS the column membership
priority: high # low | normal | high
parent: plugins-epic # optional; must be a card id on the board
blocked_by: [registry-refactor] # optional; ids must exist
tags: [plugins]
assignee: pedro
size: M
source: folio#feat/board
type: bug
link: https://example.com/issues/42
created: 2026-07-10
milestone: "0.6" # optional; quote it (bare 0.6 is a YAML float)
order: 200 # optional explicit rank; the ordering exception
artifacts: # typed attachments, one line each
- doc: docs/research/kanban-design.md
- file: folio/docs/integrations/kanban.py#L80
- pr: 23
- pr: https://github.com/acme/widgets/pull/42
- url: https://example.com/spec
label: External spec
---
The description is Markdown before the first reserved section heading.
## Acceptance criteria
- [x] board renders
- [ ] docs written
## Trail
- 2026-07-10 @pedro: card created
- 2026-07-12 @claude (abc1234): loader landed; moved to in-progressFrontmatter keys
The complete set; any other key warns and is ignored, naming the nearest known key when one is close. Required:
| Key | Rule |
|---|---|
title | Non-empty string. Missing or empty fails the build. |
status | Non-empty string; must be a column id declared in board.yaml. Anything else fails the build. |
Optional (a bare key: with no value is YAML null and treated as absent):
| Key | Rule |
|---|---|
tags | List of strings; non-string entries are filtered, a non-list value becomes an empty list. |
assignee | One name or a list: assignee: ana and assignee: [ana, bo] both work. Names are stripped; duplicates collapse. |
size | The one closed vocabulary: S, M, L, or XL (any case in the file; the board shows uppercase). Anything else fails the build naming the file. |
source | Where the work lives: a branch, repo#branch, a pull request as repo#47, or a URL. Free string like type. The card dialog draws it as a tile with a branch, pull-request or link glyph; when the site’s project.repo is a GitHub URL, a branch or PR ref under the same owner links to it. On the board the ref also filters by its shape: source:"pr", source:"branch", source:"url", and source:none for a card not started in code. |
source_title | The name of what source points at, usually the pull request’s title. Free string; the tile shows it above the ref. The build never asks the hosting provider for it. |
track | Optional workstream inside one project. Free vocabulary. |
project | Not authored on a card: one written there warns and is ignored. When kanban.sources maps several boards into one, each card takes the name of the source it came from, and that is what the board filters and the roadmap matches milestones against. |
type | Plain string, stripped. Free vocabulary for the kind of work (bug, plan, feature); the types that exist are the types cards use. The board colours it: bug red, feature green, plan violet, release amber, epic pink, chore teal, task blue, maintenance olive; any other name keeps a stable colour from the same eight, on the card face, in the dialog and in the filter. |
link | URL string. http(s), mailto, and relative paths are accepted; javascript: and data: fail validation. |
priority | low, normal, or high (case-insensitive). Any other value warns and is treated as normal. |
order | Numeric rank, the explicit-ordering exception. A non-numeric value warns and the card is treated as unranked. |
created | ISO date YYYY-MM-DD, used for sorting. Any other value silently sorts last, as if the card were newest. |
milestone | Free string naming this product’s release (quote it in YAML), such as 0.2. |
parent | Must be an existing card id on this board, and not the card itself or one of its descendants; otherwise the build fails, and a cycle names every card in it. |
blocked_by | Must be a list; every entry must be an existing card id and not the card itself; otherwise the build fails. A blocker is open until its card sits in the last-listed column. |
artifacts | List of one-line artifact entries (below); anything malformed fails the build. |
The card detail lists its dependencies and the cards that require it, with their titles and current columns. Select a related card to open it in the same workspace, including when it is outside the active filter. A dependency in the last-listed column is resolved. These links do not modify cards; editing dependencies remains a separate action on a live board.
When the config declares a versioned roadmap, a milestone matching a phase milestone (or its version fallback) lets phase pages deep-link a filtered board. A milestone no phase claims draws a warning, never a failure.
Artifacts
Five kinds: doc, api, file, pr, url. The committed form is a one-line single-key map, with an optional sibling label: on any kind.
A card with a directory does not list its files. What sits at the top level of board/cards/<id>/ is that card’s artifact list, derived at load: one entry per regular file, sorted by name — .md and .mdx become doc, everything else file. Dotfiles, _-prefixed names, subdirectories, and symlinks are skipped, the same lines publishing draws. The frontmatter block remains for what is not a file, and for labels:
artifacts:
- doc: prototypes-compared.md # names a sibling: this line adds the label
label: Five layouts compared
- doc: docs/board/index.md
- pr: 23
- api: my_package.module.load_board
- url: https://example.com/spec
label: External specA doc: or file: entry naming a top-level sibling — as the bare name, as ./name, or as the full project-relative path older boards carry — attaches its label to the derived entry instead of adding a second one. Derived entries come first, name-sorted; the remaining frontmatter entries follow in written order.
Targets read the way a markdown link reads. A relative doc:/file: target resolves against the card’s directory first, then the project root. Every artifact records what was written beside where it resolved, and the tile shows what was written: plan.md, not a repetition of the directory the card already stands in.
Validation is fail-fast on shape, and warns on reachability:
- Each entry must be a mapping with exactly one kind key (plus the optional
label:); anything malformed fails the build. pr:must be a positive integer or a fullhttp(s)URL. A URL links to that exact address; a number stays unlinked because the board does not infer a repository.url:targets pass the shared href scheme policy:javascript:anddata:fail the build.api:targets only need to be a non-empty string; symbol existence is not validated yet.- A
doc:/file:target may carry a#L12-style fragment, stripped before checking. An absolute path or one escaping the project directory fails the build. One that resolves to no file warns, naming the card and the written target, and the tile renders unlinked — a stale path in one card is prose, not board topology.
Four kinds of artifact open. A url: opens because it was written as a URL. A pr: opens when its target is a full URL; a numeric PR remains unlinked. These hrefs are recomputed at build time and never trusted from input, so they cannot smuggle a javascript: past the scheme policy. A doc: or file: opens when it points inside the card’s own directory, described next. And a doc: naming a file under a source.docs directory opens the documentation page the site builds from it; a doc: whose file exists but whose page nothing publishes warns at build, because doc: promised a page.
Everything else renders as an unlinked tile showing its target. The board builds no URLs for whoever happens to host the repository: that assumed one host and produced a dead link for anything not committed there. Attaching a file is also not a license to publish an arbitrary part of the project.
A card’s directory
A card may keep a directory beside its markdown file, named for the card (see the index for how published artifacts open):
The card file sits beside its directory. The directory holds what the card produced — and its top level is the card’s artifact list, derived as described above, so putting a file there and attaching it are one act. A label goes on in the frontmatter, by the sibling’s bare name:
artifacts:
- file: wide-reader.html
label: Wide reader (chosen)The build owns every file in that directory. Markdown and MDX become Folio pages under the configured docs route; other files are served verbatim at /_folio/kanban/<card-id>/. A derived artifact therefore is a link the reader can follow.
The rules are short:
- The whole directory is published, not only what
artifactsnames. The files reference each other, and a page without its stylesheet opens as nothing. - Markdown and MDX are compiled by Folio.
report.mdbecomes<docs route>/kanban/cards/<card-id>/report/, with the project theme, search entry, sitemap entry, Markdown mirror,llms.txtentry, link validation, local-image copying, and incremental rebuild tracking. A leading_opts out of compilation. - Compiled pages stay out of the docs sidebar. A card’s output answers to its URL and stays findable through search, the sitemap,
llms.txt, and the Markdown mirror; the sidebar is the documentation’s own table of contents, and board working papers are not part of it. - Every folder route above a document resolves. Compiled documents sit below
<docs route>/kanban/cards/<card-id>/, and readers reach those URLs by trimming a longer one or from a breadcrumb, so none of them 404s.- An
index.mdorREADME.mdin a folder (the card’s directory or any subdirectory) becomes that folder’s page. - A folder that ships neither gets a generated index: the card’s status line and first paragraph, then one tile per document and per attached artifact. A published prototype opens; an unpublished target stays an unlinked tile.
- The generated page carries the plugin’s marker and steps aside: for a card-authored index the moment one appears, for a documentation page of yours that already owns the folder’s URL, and entirely when the documents are gone.
<docs route>/kanban/cards/itself resolves to a directory of the publishing cards while anything publishes.- With
routes.docs: falsethe parent resolves too:<docs route>/kanban/forwards to the public board, query and hash intact. A board published nowhere gets a directory of the cards that publish documents instead of the board page the configuration turned off.
- An
- Everything else stays a raw bundle. HTML, CSS, JavaScript, images, and the original source files are copied under
/_folio/kanban/<card-id>/without rewriting, so an HTML prototype keeps loading its sibling assets. - Dotfiles and dot-directories stay behind.
.verify/,.cache/and friends are session scratch by convention. - Ownership is exact. An artifact cannot use
../to claim another card’s directory. - Symlinks are refused, not followed. That includes the card directory itself and files below it, so a link out of the project cannot publish a file the project does not contain.
- The top level is the band on the card. Only the directory’s top level derives artifacts; a subdirectory still publishes (a prototype keeps its assets) but is not listed, so curation is placement.
- A directory whose card is missing is reported. The filename stem names the directory; a card renamed or deleted leaves its directory orphaned and unpublished, and the build and
folio-agents board checkwarn, naming it. - Nothing else about the card changes. The card is still
board/cards/<id>.md, still found by the same glob, and a card without a directory is unaffected.
What belongs in a card directory and what does not is a session convention, not a build rule: What a session leaves behind.
Body structure
- Description — everything before the first top-level
## Acceptance criteria,## Comments, or## Trail. Other headings stay in the description. Headings inside fenced examples or Folio component children do not delimit sections. ## Acceptance criteria— bullet lines of the form- [ ] textor- [x] textbecome checkboxes. Lines that do not match are silently skipped.## Trail— one line per work session, oldest first, matching this grammar:
- YYYY-MM-DD @actor (ref): noteThe (ref), a commit sha or PR #n, is optional. The reader is tolerant: a line that misses the grammar warns at build time and still renders as an ordinary note (whose inline markdown renders like any other, per the dialog section). The writer (folio-agents board trail) is strict: the date must be YYYY-MM-DD, the actor a single token, the ref free of parentheses and newlines, the note non-empty.
A ref renders as the identifier it was written as, in mono, and the board builds no link from it. A sha is already an address and git show takes it; guessing which host would answer for it is how the board ended up printing dead links.
This is also the reason artifacts has no commit kind and does not need one. Git is the source of output history, and a ref that has already been written down once should not have to be written twice.
## Comments— the card’s conversation, one line per comment, the trail’s grammar minus the ref:
- YYYY-MM-DD @actor: textThe trail records what happened; comments argue about it. Same tolerance contract: a line that misses the grammar warns at build time and still joins the thread as prose. The writer (folio-agents board comment) is strict: date, single-token author, non-empty text with whitespace collapsed. The dialog draws the thread as its own band above the artifacts.
Ordering inside a column
Cards sort deterministically by (has-rank, rank, priority, created, id):
- Cards with a numeric
ordercome first, sorted by rank ascending. - Unranked cards follow, by priority (
high,normal,low), - then by
createddate (older first; missing or malformed dates last), - then by id, lexicographically.
order is the rare explicit-ordering exception; everything else is computed, so reordering never has to touch another card’s file.
Validation
The contract: a typo in a note must never break validation; a typo in board topology must never silently pass. folio-agents board check is the canonical gate. The optional Docs adapter consumes the same loader.
Fails the build (and check exits 1):
board.yamlmissing, unparseable, or without acolumns:list- a column without a title
- duplicate column ids
- a missing
cards/directory - a card filename that is not a slug
- missing, unparseable, or non-mapping frontmatter
- a missing
titleorstatus - a
statusthat is not a column id - a
sizethat is notS,M,L, orXL - a dangling or self-referential
parent, or aparentchain that comes back to a card - a
blocked_bythat is not a list, or that contains a dangling or self-referential id - an
artifactsvalue that is not a list - a malformed artifact entry
- a non-positive
pr:number - a
doc:orfile:path that is absolute or escapes the project directory - a
url:artifact or a cardlinkfailing the href scheme policy
Warns only (build and check succeed):
- a column over its WIP limit
- an invalid
limitvalue inboard.yaml(ignored) - a trail line that misses the grammar (kept, rendered as plain text)
- an unknown
priority(treated asnormal) - an unknown frontmatter key, or a
projectwritten on a card (ignored) - a non-numeric
order(treated as unranked) - a milestone no roadmap phase claims (only when the config declares a versioned roadmap)
- a card past the first column without a
milestone(only when the config declares a versioned roadmap): the first column is the intake and may stay unscheduled; a card that leaves it names its release - a
doc:orfile:target that resolves to no file (the tile renders unlinked) - a
doc:whose file exists but whose page nothing publishes (build only —checkruns without the site’s source configuration) - a card directory whose card is missing (nothing publishes it)