Configuration reference
The vault’s settings live in .cuaderno/config.toml, written by cdno init. Every key is optional —
defaults are applied when omitted. For the conceptual tour, see
Configuration.
Full example
[vault]
name = "My Research Vault"
method_version = "1.2" # the RLM version; written by init, changed by `cdno migrate`
max_active_projects = 5 # the active-project cap
# Glob patterns excluded from the index (search, lint, link checks).
# Additive only — no "!" negation. Matched against vault-relative paths.
# NEVER deletes files; only scopes what the index considers.
ignore = ["CLAUDE.md", "README.md"]
# Per-type extra required frontmatter fields. Built-in required fields
# are always enforced; these add vault-specific requirements (cdno lint).
[schemas.project]
extra_required = ["collaborators"]
[schemas.evidence]
extra_required = []
# Typed frontmatter fields for a built-in type. Recognised by the
# Templates editor and type-checked by `cdno lint`.
[schemas.daily.fields.meds]
type = "bool" # bool | int | float | string | date
default = false # static, type-checked against `type`
[schemas.daily.fields.mood]
type = "string"
values = ["low", "ok", "good"] # allowed values (a string constraint)
default = "ok"
# How an activity's tracked numbers are read back. Each metric declares how
# it collapses to one point per date; without a declaration the activity's
# series come from its body table, with each column summed.
[tracking.practice]
records = "detail" # frontmatter key holding repeated records
group_by = "subject" # one series per distinct value
[tracking.practice.metrics.minutes]
aggregate = "sum" # a TOTAL
unit = "min"
[tracking.practice.metrics.focus]
aggregate = "mean" # a RATING; sum would grow with how often you log
# Static template variables — resolve in any custom template ({{author}}).
[variables]
author = "A. Researcher"
# Prompted variables — gathered at note creation (--var, prompt, or error).
[variables.prompt]
collaborators = "Who are the collaborators?"
Keys
| Key | Type | Default | Purpose |
|---|---|---|---|
vault.name | string | "My Vault" | A human label for the vault. |
vault.method_version | string "<major>.<minor>" | absent (read as 1.0) | The version of the Research Logbook Method the vault follows, not cdno’s version. Written by cdno init; changed only by cdno migrate. See Method version. |
vault.max_active_projects | integer | 5 | The active-project cap: how many projects may count, that is, be active and name no one else in held_by:. |
ignore | list of globs | [] | Files the index skips. Additive; never deletes. See Ignore globs. |
schemas.<type>.extra_required | list of strings | [] | Extra required frontmatter fields for a built-in note type (not enforced on a custom type), enforced by cdno lint. |
schemas.<type>.fields.<name> | table | — | A typed frontmatter field for any note type, built-in or custom (type, default, required, values, settable, log_on_change). Recognised by the Templates editor (built-in types), type-checked by cdno lint, and (when settable) writable via cdno frontmatter set. See Typed schema fields. |
tracking.<activity> | table | — | Declares how an activity’s tracked numbers are read back (records, group_by, and a metrics.<name> table per metric). Without one, the activity’s series come from its body table with each column summed. See Tracking. |
focus.carry_over_days | integer | 1 | How many days back the focus is read from today. 0 reads today only. See Focus. |
focus.paused_lookback_days | integer | 14 | How far back a pause is still offered for resume, cdno now and orientation. |
focus.max_window_days | integer | 366 | Ceiling for the two windows above; a vault that exceeds it fails to open. |
projects.holder_sentinels | list of strings | ["me", "I", "myself", "(mine)", "someone", "somebody", "nobody", "unknown", "tbd", "n/a", "none", "-", "?"] | Values of a project map’s held_by: that name you or nobody, so they read as no holder. Trimmed, any case; [] accepts any name. See Projects. |
note_types.<name> | table | — | Declares a config-defined custom note type (folder, required/optional fields, template, …) — a schema-only type for entities the built-ins don’t cover. See Custom note types. |
variables.<name> | string | — | Static template variable; resolves in any custom template (per-type values win on name clash). |
variables.prompt.<name> | string | — | Prompted template variable; the value is the prompt text. Gathered at creation from --var name=value, an interactive prompt, or a static [variables] default; errors if none supplies it. |
Method version
[vault] method_version records the version of the
Research Logbook Method the vault follows, as two numbers, "1.1": no patch
part and no v. It is the method’s version, not the crate version cdno is released under, and it
changes only when the method’s text does.
[vault]
method_version = "1.1"
cdno initwrites the version this cdno implements. A vault with no key (one created before the key existed) is unstamped and read as RLM 1.0; nothing breaks.- Only
cdno migratechanges it: runcdno migrate --dry-runto see what it changes, thencdno migrate. Do not edit it by hand, since a migration’s file steps and its log entry go with the stamp. It is never changed when a vault opens, and never over MCP. - Every write re-reads the key under the write lock. A vault on an older major version than
this cdno is refused until you migrate it; a vault on a newer major is refused until you
upgrade cdno. Reads keep working in both cases. A different minor version writes normally;
cdno prints a one-line notice on stderr and
cdno lintmentions a newer one. - A malformed value (
"v1","1.1.0") is an error naming the key. - The key stays in
[vault], which older binaries read without rejecting unknown keys, so a vault stamped by a newer cdno still opens in an older one.
See Troubleshooting.
Ignore globs
ignore lists files that live in the vault directory but are not notes — repo scaffolding like
CLAUDE.md or README.md. They are excluded from the index, and therefore from search, lint and
backlinks as well. The files are never touched on disk.
Patterns are matched against each file’s vault-relative path:
| Pattern | Matches |
|---|---|
CLAUDE.md | that file at the vault root |
**/*.draft.md | a .draft.md at any depth |
folder/*/** | everything one or more levels below folder/<anything>/ |
folder/*/*/** | everything two or more levels below folder/<anything>/ |
The last two are the trap worth knowing. * stays inside one path segment but ** is recursive, so
portfolios/*/** does not mean “the level below a portfolio” — it matches the portfolio’s own notes
as well as anything nested under them. A glob written that way excludes every note in the folder it
was meant to tidy, and because an unindexed note is also unsearchable and unlinkable, the symptom
looks like a broken view rather than a misconfigured vault.
One thing guards against that: cdno reindex prints how many files the globs excluded, so an
over-broad pattern shows up as a number rather than as notes that quietly stopped appearing.
If notes go missing, clear the pattern and run cdno reindex: every row comes back.
Note that attachment artefacts filed into a portfolio need no ignore entry — they are excluded
automatically, by location. See vault structure.
Typed schema fields
[schemas.<type>.fields.<name>] declares a typed frontmatter field for any note type, built-in or
custom. It is the richer sibling of extra_required: instead of just a name, each field carries a
type (and optionally a default and an allowed-value set). Four things consume it today:
cdno templatesrecognises the field (built-in types only; a custom type’s placeholders are itsrequiredandoptionalfields, so declare the field there too), so a custom template referencing{{<name>}}no longer warns “renders literally”;- note creation populates the field’s
defaultat create — a custom template referencing{{<name>}}renders that default (a field with no default rendersnull), so the value lands in the new note’s frontmatter instead of a literal{{<name>}}(built-in types only; a custom type’sdefaultis not written — see Custom note types); cdno linttype-checks the field — a note whose value doesn’t match the declared type (or isn’t one ofvalues) gets a warning;- the
set_frontmattersetter (cdno frontmatter set, MCPset_frontmatter) writes the field through the index when it is markedsettable = true— see thecdno frontmatterreference.
[schemas.daily.fields.meds]
type = "bool" # bool | int | float | string | date
default = false # optional; static, type-checked against `type`
settable = true # optional; allow `set_frontmatter` to write it (default false)
log_on_change = true # optional; stamp a daily-log line when it changes
[schemas.daily.fields.mood]
type = "string"
values = ["low", "ok", "good"] # optional; allowed values (only valid on a string)
default = "ok"
required = false # optional; default false
| Field key | Type | Default | Purpose |
|---|---|---|---|
type | "bool" | "int" | "float" | "string" | "date" | (required) | The field’s scalar type. An unknown value is a hard load error. |
default | matching type | — | A static default value, type-checked at load. Populated at create when a custom template references {{<name>}}. A date is a quoted "YYYY-MM-DD". |
required | bool | false | Reserved for create-time enforcement (a later release); parsed now, but inert — it does not yet block creation. |
values | list of strings | — | An allowed-value constraint. Valid only on a string field. |
settable | bool | false | Whether set_frontmatter (cdno frontmatter set, MCP set_frontmatter) may write this field. Default-deny: absent or false means not settable. Never overrides an engine-owned key (type, status, a period key, and a project’s closed) — those stay blocked regardless. |
log_on_change | bool | false | When a settable field’s value actually changes, stamp a key: old → new line into today’s daily note in the same commit. |
Notes and limits:
- Defaults are static — there is no
"today"token; adatedefault is a literal calendar date. - A field only lands in frontmatter if a custom template references it — rendering substitutes
the
{{<name>}}tokens a template contains; it never adds a frontmatter line. The shipped built-in templates can’t reference vault-specific fields, so populate a declared field by adding a custom.cuaderno/templates/<type>.mdthat references{{<name>}}. - A create-path value wins over a declared default — if the note type’s create path already
supplies a value for that name (an engine-supplied placeholder), that value takes precedence and
the declared default does not apply. Likewise a
[variables]static var of the same name wins over a schema default. - A
[variables.prompt]name is owned by the prompt — if a field name is also a prompted variable, its value is collected via the prompt (from--var, an interactive prompt, or a static default), and the schema default is not used. This ensures a supplied answer is never discarded. - No
enumtype — model a closed set as astringwithvalues. intandfloatare distinct —intrejects anything with a fractional part, so a currency amount, a measurement or a rate wantsfloat. Afloatfield accepts a whole number too (82and82.5both validate), because a round reading is written without a decimal point. A non-finite default (nan,inf) is a load error — those values have no meaning as data.- List fields are reserved but not yet implemented — a
list = trueis a load error today. - Engine-owned keys are protected — you can’t declare a field named
type, or a calendar type’s own period key (daily→date,weekly→week,monthly→month); the vault refuses to open.set_frontmatteradditionally refuses to writestatusfor every type — even if a vault declares astatusfieldsettable = true— so the lifecycle commands stay its sole writers. extra_requiredstill works and is equivalent to an untyped, non-requiredstringfield; on a name clash an explicitfieldsblock wins.- A malformed field declaration (unknown
type, a mistyped key,valueson a non-string, adefaultthat doesn’t type-check) fails at vault-open, like every other config error.
Tracking
[tracking.<activity>] declares how an activity’s numbers are read back. Without one, a tracking
note’s series come from the first table in its body, with each column summed — right for a rep
sheet, wrong for a balance or a rating. Declaring an activity moves it to frontmatter, where each
metric says how it collapses.
[tracking.practice]
records = "detail" # frontmatter key holding repeated records
group_by = "subject" # one series per distinct value of this field
[tracking.practice.metrics.minutes]
type = "int"
aggregate = "sum" # a TOTAL
unit = "min"
plot = "column"
[tracking.practice.metrics.focus]
aggregate = "mean" # a RATING - a sum would grow with how often you log
| Key | Where | Purpose |
|---|---|---|
records | activity | Frontmatter key holding a sequence of flat records. Omit for plain scalars read straight off the entry. |
group_by | activity | Record field the series split on — a category, a subject, a person. One series per distinct value. |
at | record | Not a config key but the per-record field that orders a set, so last reads the reading you meant. HH:MM, HH:MM:SS, or a 12-hour time with a meridiem. Needs no quoting — the colon keeps it text — but a colon-less at: 1800 is a number and never reaches the parser. All-or-nothing: cdno lint reports a value it cannot use, or a set only partly stamped. |
type | metric | bool | int | float | string | date. Optional. |
aggregate | metric | sum | mean | last | max | min. Defaults to sum. |
group_by | metric | Overrides the activity’s. "none" collapses across records for an entry-level series. |
derived | metric | An expression computing this metric from sibling fields, e.g. "km * rate_per_km". Evaluated per record, before aggregation. Declare type on it and the vault refuses to open. |
unit | metric | Display unit (min, kg, EUR). Carried through to the chart and the MCP series. |
label | metric | Display name for the series, when the metric’s key is not what you want on a chart (resting_hr → Resting heart rate). |
plot | metric | none | line | column | area | scatter. Defaults to none. Chooses the mark the chart draws, and whether a chart consumer draws it at all — see the note below. |
Derived metrics
Some tracked quantities are products of others — a cost from a rate and a distance, a load from a weight and a count. Rather than making whoever writes the entry pre-compute them, declare the expression:
[tracking.commute.metrics.km]
aggregate = "sum"
[tracking.commute.metrics.rate_per_km]
aggregate = "last"
[tracking.commute.metrics.cost]
derived = "km * rate_per_km"
aggregate = "sum"
unit = "EUR"
It is evaluated per record, then aggregated — not derived from the aggregates. With two trips
of 10 km at 0.50 and 20 km at 0.25, cost is 10.00; deriving from the totals would give a
different, wrong number the moment the rate varies.
The grammar is deliberately tiny — one binary operation, nothing else:
expr := operand OP operand
operand := field-name | number
OP := '+' | '-' | '*'
- No
/. Division is the one operator that manufactures NaN and infinity, and those must never reach an aggregate. Multiply by the reciprocal, or pre-compute the ratio. - No parentheses, calls, chaining or recursion, and no deriving from another derived metric.
- Field names are letters, digits and
_, not starting with a digit. Hyphens are excluded becausea-bwould be indistinguishable from a subtraction. - Numbers are plain decimal — digits, an optional leading
-, an optional.. Exponent notation (1e-3) is not supported; write the value out in full. - Every operand must name a metric the same activity declares. A typo is a vault-open error
naming the field, rather than a silently empty chart. The cost of that requirement: an operand
that exists only to be multiplied — a rate, say — still becomes a metric of its own. Leave its
plotundeclared (the default) and it is not charted alongside the result — see below. typemust be omitted — the output is numeric by construction, so declaring one can only contradict it.- A record missing an operand contributes nothing — a gap, on the same rule as a plain metric. So does a result that is not finite.
Choosing the aggregate is the whole point, and it follows from what the number is:
| Kind | Examples | Aggregate |
|---|---|---|
| Total | amount spent, pages read, minutes practised | sum |
| Level | account balance, a measurement, a top set | last, max |
| Rate or rating | a score out of ten, perceived difficulty | mean |
Notes and limits:
- An absent
[tracking]section is not an error — an undeclared activity keeps being served from its body table, so nothing forces a migration. - Declaring is checked at vault-open, not at first chart render: an unknown
aggregate, a mistyped key, or a blankrecords/group_byfails when the vault is opened, naming the key. - An activity may declare no metrics at all. That is a complete use — recording that something happened, with nothing to aggregate.
windowis reserved for a future time-reduction axis (month-over-month deltas, rollups) and is a load error today, so adding the behaviour later is not a breaking change.- A declared activity’s body table is no longer read once its frontmatter yields a series, so
the same metric can never appear twice under two disagreeing numbers. This rule is unconditional
— it keys on the frontmatter derivation’s full produced set, not on what any individual metric’s
plotsays, so a declared-but-unplotted metric still suppresses its body-table equivalent. plotchooses the mark, and gates whether it is drawn at all. A declaredline/columnis used as the chart’s mark (anareaorscatterresolves to the closest of the two the chart draws).plot = "none"— the default for a declared metric that names no mark — is still emitted and still queryable over MCP, but left out of any chart pane. Declaring an activity is an explicit act, and is allowed to change what is drawn: its frontmatter series replace its body-table ones (the rule above, unaffected by this), and only the metrics that opt into a mark are charted.
Focus
The [focus] section sets how far back the focus is read.
All three keys are optional, and an unknown key in the section is a hard error, so a typo cannot
silently turn a window off.
[focus]
carry_over_days = 1
paused_lookback_days = 14
max_window_days = 366
| Key | Default | Meaning |
|---|---|---|
carry_over_days | 1 | The focus is the newest open start or resume among the daily notes from today - carry_over_days to today, unless a close follows it. 1 means a start left open yesterday is still the focus this morning and expires after that; 0 reads today only, as before the window existed. Calendar days, not rolling hours. |
paused_lookback_days | 14 | How far back a pause is still offered: by cdno action resume, by the “Last paused” line of cdno now, and as last_paused in the MCP orientation and project-context reads. 14 covers a weekend and most holidays; raise it if you are away longer. |
max_window_days | 366 | A ceiling on the two windows. A vault whose carry_over_days or paused_lookback_days exceeds it fails to open, with an error naming the key, its value and the ceiling, so a typo like 14000 cannot turn every read into thousands of file checks. Raise it if you really want a longer window. |
Both windows are read from the daily log each time; nothing is cached, so changing a value takes
effect on the next cdno command. A running MCP server keeps the config it started with, so
restart it (reconnect the client) for an agent to see the change. See cdno now and
Troubleshooting.
Projects
The [projects] section holds policy for project maps. Its one key, holder_sentinels, lists the
values of a map’s held_by: (the name of whoever else is moving the map) that name you or nobody.
Such a value reads as no holder, so the map stays yours and counts, exactly as a map with no
held_by: line does. The key is optional; leaving it out gives the default below, so a vault that
never sets it behaves as before. An unknown key in the section is a hard error, as in [focus], so
a typo cannot silently keep the default.
[projects]
holder_sentinels = [
"me", "I", "myself", "(mine)",
"someone", "somebody", "nobody", "unknown", "tbd", "n/a", "none", "-", "?",
]
| Key | Default | Meaning |
|---|---|---|
holder_sentinels | the list above | Values that name you (me, (mine), which is how the log writes “no holder”) or nobody (tbd, ?). Each value is trimmed and compared in any case, so " TBD " matches tbd. Setting the key replaces the whole list: add your own name or your own language’s words (holder_sentinels = ["yo", "nadie"]), or set [] to accept any name. |
The limit. cdno cannot know your own name. held_by: "<your name>" reads as someone else, so
the map stops counting, unless your name is on the list. If you want it refused by
cdno project hand-off and read as yours, add it (and keep
the default entries you still want, since setting the key replaces the list).
A blank held_by: names nobody under any list, so it is not in the default and you need not add
it. A held_by: that is not a plain name (an unquoted [[people/ana]], a number, a date, quoted
or not) also reads as no holder; quote a wikilink (held_by: "[[people/ana]]") for it to count as a
name.
Templates
Templates live in .cuaderno/templates/ and are pure variable substitution. cdno init writes two
starters (daily.md, and concept.md for the concept type it declares); other built-in types use
their built-in default until you add a file. cdno selects the
most specific template that exists: a custom variant (e.g. tracking-gym.md), then a custom type
(e.g. project.md), then the built-in variant default, then the built-in type default. Template
field order is the canonical order
cdno normalise enforces.
The per-type placeholders that resolve at creation, with a worked example, are in Customising templates and frontmatter. An unknown placeholder is left verbatim in the note.
Static [variables] resolve in any custom template (e.g. {{author}}). Prompted
[variables.prompt] are gathered at creation (via --var name=value, an interactive prompt, or a
static default) — see the
tutorial.
Editing from the CLI
cdno config edits this file without hand-editing it, and every write goes through the same gate:
cdno config show # verbatim, comments and key order intact
cdno config validate # the exact check running a vault performs
cdno config edit # $EDITOR round trip
cdno config note-type set --name people --folder people --required name,email
cdno config field set --note-type people --field email --type string
cdno config var set --name author --value "Your Name"
Saving is gated so an edit can never leave the vault unopenable:
- The whole candidate is validated first — TOML parse,
ignoreglobs, and the[note_types.*]/[schemas.*]rules. If it would not reopen, the save is refused and the file is left untouched. - A content-hash compare-and-swap then guards against a concurrent hand-edit: if the file changed on disk since it was read, the save is refused rather than overwriting the newer version.
- Only then is the candidate written verbatim, so comments and key order survive.
The structured setters merge: a flag you do not pass keeps its current value, so changing one key
cannot silently drop the rest of an entry. Pass an empty value to clear one (--template ''), or
the paired negative flag for a boolean (--no-append-only).
See the config reference for every subcommand.
See also
- Customising templates and frontmatter — the tutorial.
- Configuration — the conceptual overview.
- Frontmatter fields — what
extra_requiredextends. - Custom note types — the
[note_types.*]table in full.