Card format
A .card file: naming, frontmatter and body, the shared body tags, attachments, and refs.
What it is
Status: Implemented (2026-05). Cards are YAML frontmatter + a markdown body. The legacy XML card format, its loader, and the cardworks package have been removed.
This is the living reference for the card file format — filenames, frontmatter/body split, attachments, and refs. For the full design record (why markdown over XML, alternatives considered, per-schema migration notes, phased rollout) see docs/implemented-plans/cards-as-markdown-rfc.md. For adding a card type, see schemas. For the on-disk box layout, see box layout.
Format
--- status: new created: 2026-05-22T10:00:00Z source: dropbox --- Body content as plain markdown, if the schema declares a body field.
A .card file is a YAML frontmatter block (--- fences) optionally followed by a markdown body. src/cards/frontmatter.ts splitCardContent() locates the block (pure string split, no YAML parsing); src/core/card-io.ts parses the frontmatter with the yaml package and validates it against the card type's registered CardSchema.
Naming and type discrimination. Name.type.card — the type segment is the canonical discriminator, not a type: frontmatter field. src/core/card-io.ts typeFromFilename() reads it off the filename; serializeCardText() never writes a type: key back out. (A YAML type: field is tolerated on read for backward compatibility, but must match the filename's type or it's a format error.) Any file matching <basename>.<type>.card where <type> isn't registered in cardSchemas is an error; anything not matching that pattern is just not a card (e.g. README.md). A card filename must not contain ?: card links (/<box>/views/<path>) read it as the start of a query, so bbx validate reports it as an error.
Schemas are declared with cardSchema(type, config) from src/cards/ (or the beebox/cards public specifier for box-local schemas under src/schemas/). config.fields is a flat map of Zod validators — one of which may be wrapped in body(zodSchema) to mark it as the file's markdown body instead of a frontmatter key. At most one field may be body-wrapped, and it must be named body; a schema that declares none is frontmatter-only, and any non-whitespace body content on such a card is a load error. Every schema also gets seven optional frontmatter fields for free (title, contains, contains-evidence, todos, symbol, prominence, theme; GLOBAL_CARD_FIELDS in src/cards/schema.ts, described under schemas) unless it declares its own. See schemas for the full authoring walkthrough, including the per-schema validate hook for cross-field rules Zod can't express.
Body content and Markdoc. Where a schema declares a body, it's plain markdown text rendered through a shared Markdoc configuration (src/shared/markdoc-config/tags/core.ts) rather than plain CommonMark — this is the same config the frontend renderer and bbx validate's body-ref walker both use. It defines a shared vocabulary of inline/block tags ({% quote %}, {% source %}, {% ingredient %}, {% task %}, {% todo %}, {% see-also %}, etc.) available across card bodies; a card type doesn't declare its own tag set, it just uses whichever shared tags make sense in its body prose.
{% todo %} and {% see-also %}
todo is a universal capture-in-place annotation — it marks a span of body prose (or, via the frontmatter todos: key below, an intention that doesn't belong to any particular sentence) as an open item. It's a wrapper tag, inline or block depending on whether its content has a blank line in it (same node.inline split as {% quote %}):
Call the vet about Biscuit's prescription refill Dana offered to pick it up
All attributes are optional — a bare {% todo %}…{% /todo %} is a valid open todo. Full vocabulary:
id— a short, human-scale slug for cross-reference (unique box-wide, enforced by the collector, not per-card lint). Never a UUID.status— one ofopen(absence =open),done,dropped,parked.done/droppedrender struck;parkedis deliberately out of the now/later split (excluded from default surfaces, present in the full list).assigned— plain string; absence = the boxholder."agent"marks work the agent itself is chasing. Any other value names a person ("Dana"); a plain name is acceptable, and whether it should be a ref is not settled yet. Only the boxholder's todos count as due now; the agent's and another person's render on their card with an assignee badge. A placeholder for the boxholder (user,me,boxholder,you,owner, any case) draws a lint warning: the boxholder's todos leaveassignedoff.by— provenance: absence = boxholder-authored,"agent"= agent-authored.created— ISO date (YYYY-MM-DD). Required whenby="agent"; optional for humans.due— ISO date; past due escalates the todo (the "oh shit" line).start— ISO date, or a relative interval (-3d,-2w) meaning due-minus-that-interval (an error if relative with nodue). This is the primary surfacing trigger: the todo is "now" fromstartonward, quiet before it. An undated todo (nostart, nodue) is on the now immediately — quiet states are always explicit, never an accident of missing metadata.recheck— ISO date, ornever. The dailytodo-reviewprocedure's bookkeeping: it does not list the todo before that date, and never lists it oncenever. Written by the review agent (a date 1-90 days out) and by the review's verify step (never, after three unchanged rechecks, reported by thetodos-unreviewedhealth warning). A retired todo that is edited comes back into review; aneverset by hand is respected. It never affects the now/later state, the badge, counts, or order.
see-also nests inside a todo (zero or more) and points at supporting context: exactly one of ref (an in-box card, tracked by bbx mv) or href (an external URL) — a target-less see-also is meaningless. Its body is the reason. Renders footnote-style, not as a block interruption.
Frontmatter todos: — for intentions that don't attach to any particular sentence of the body, every card also accepts a todos: list, one entry per todo, same attribute names as above (text replaces the tag's body):
todos:
- text: "Renew the parking permit"
due: 2026-08-15
- text: "Ask Marcus about the quote"
assigned: agent
status: parked
Querying and surfacing. bbx query todos is the read/query path (deterministic, locator-carrying, scoped by --here to one place and the todos elsewhere that link into it); it's designed for agent consumption, not human browsing directly. Mutating a todo is an ordinary card edit — there is no write subcommand. A todo-view card (see src/schemas/todo-view.ts) is the human-facing display surface: it's the same query (a glob plus optional todo-status/assigned filters), and — per "views attach to cards" — dropping one in a directory gives that subtree its own todo page. _content/plate.todo-view.card is the box-wide instance. Design record: docs/implemented-plans/todo-annotation.md and docs/implemented-plans/todo-collection.md.
Where a todo sits is what it means. Both surfaces group todos by the card they were written in, then by the heading above them, then under the todo they nest beneath — so put a todo under the heading it belongs to. A note written after the closing tag travels with the todo, and a link inside a todo makes it surface on the linked place too.
The suggested way to make a todo list at all is a simple .doc.card with embedded {% todo %} items — there is no dedicated todo-list card type.
Attachments
Name.attach/ — a sibling directory holding files that belong to Name.type.card. No reserved top-level attachment directory; every card gets its own scope next to it:
Voice_note.doc.card
Voice_note.attach/
recording.audio.card
recording.attach/
recording.m4a
Two lint rules make this unambiguous (src/lib/attach-lint.ts):
- No two cards in the same directory may share a basename — otherwise
Name.attach/would have no single owner. - A literal directory or file named
attachis forbidden outside an existing<basename>.attach/scope — it would collide with theattach/virtual ref prefix below. Names likeattachments/are fine; only the bareattachis reserved.
Inside a ref value, the prefix attach/ means "this card's own attach scope" — attach/photo-002.jpg resolves to <basename>.attach/photo-002.jpg (src/shared/attach-path.ts).
Refs
Refs are found by convention, not by per-field schema declaration: any key literally named ref whose value is a string, or refs whose value is a string array, at any depth in the parsed frontmatter (extractRefs() in src/cards/schema.ts). The same convention is walked in the Markdoc body (extractBodyRefs, src/core/body-refs.ts) for tags carrying a ref attribute (e.g. {% source ref="..." %}). bbx validate resolves every ref it finds against the box and warns (not errors, so a legitimate pending move doesn't block a commit) when a target doesn't exist.
How a ref path resolves is one rule, implemented once in resolveRefPath (src/shared/ref-path/core.ts) and shared by every consumer. A ref is written with a leading / — it resolves from the box root, which is the canonical form everywhere refs are authored. The one exception is attach/…, the card's own attach scope (above); it is legal only from a .card, since a plain .md dossier owns no attach scope. ../ is never written: a .. that climbs out of the box resolves to null (fail closed — never clamped back to the root). A bare path resolves relative to the directory of the document it was written in; that form still resolves, but it is legacy — existing boxes hold it, and nothing new should be authored that way.
participants:
- { ref: /_content/people/Alice.person.card }
sources:
- ref: /_bookkeeping/archive/articles/Article.record.card
usage: primary
Validation
Cards validate on load (a Zod parse failure is a hard error — the card can't be used) and again at commit time via the per-box pre-commit hook (bbx validate --pre-commit, installed by bbx init; see validation for the hook mechanics). Beyond the Zod frontmatter shape, bbx validate also runs: the schema's own validate hook (self-contained, cross-field checks), box-aware ref-existence resolution, and the .attach/ layout lint above.
Why markdown over XML
The prior format was XML validated by Zod schemas via a bespoke library (cardworks). The case for markdown + YAML frontmatter came down to familiarity, not raw legibility — agents and humans have seen vastly more Markdown/YAML/JSON-Schema-shaped documents than the project's own XML dialect, and that familiarity reduces the "no, write it like this" correction loop during agent authoring. Attachment-directory ownership (.attach/) and prose living in the body without a wrapper element were secondary, real benefits that arrived alongside the format change. The full case for, case against, format alternatives considered (Markdoc vs. MDX vs. directives), and the per-schema migration audit are in the RFC record.
Full design history
docs/implemented-plans/cards-as-markdown-rfc.md is the frozen original RFC: the case for/against, the format-choice survey (why Markdoc over MDX/directives/plain HTML-in-Markdown), the per-schema feasibility audit, the ref-design exploration, and the phased migration plan as originally proposed. Treat it as historical reasoning — where it disagrees with this file or with the code in src/cards/ and src/core/card-io.ts, this file and the code win.