The schema
Everything about a workflow is declared in one file. Nothing is built in, and the commands enforce whatever you wrote.
cairn.toml is the entire configuration surface. Run cairn config to see it
resolved, or cairn config --json to hand it to something else.
Statuses
[[status]]
name = "doing"
label = "in progress"
category = "active"
color = "yellow"
board = true
Order in the file is the column order on the board and the sort order in listings. Two properties carry meaning beyond display.
Category is what the program reasons about
Every status belongs to exactly one of open, active, done or dropped.
Names are what your team says; categories are what progress bars, cairn close,
default filters and checkbox rendering key off.
So you can call your statuses anything. This is a real project’s schema, and every command still works:
idea category = open
prepping category = open (shown as "mise en place")
on-the-pass category = active
served category = done
86ed category = dropped
cairn close on that project produces status: served.
It is also what makes import possible. Status names mean nothing across a
project boundary — one project’s shipped is another’s served is GitHub’s
closed — so importing maps by category, the only axis that survives the trip.
Label separates what you see from what you type
label is display only. The status above shows as “in progress” in a table,
while --filter status=doing, --plain and --json all use doing, so
scripts keep working when you reword something.
Types
[[type]]
name = "bug"
icon = "!"
color = "red"
template = """
## What happens
## Reproduction
"""
template seeds the body of every new item of that type, so bug reports arrive
with the same headings without anyone having to remember them.
Custom fields
[[field]]
name = "priority"
kind = "enum"
values = ["p0", "p1", "p2", "p3"]
default = "p2"
column = true
kind is one of text, enum, list, date, number or bool. Enum values
are ordered, so --sort priority sorts by declared severity rather than
alphabetically.
column = true puts the field in tables and on board cards. A column that turns
out to be empty for every row is dropped, so declaring a field you only
sometimes use costs nothing.
Milestones
[[milestone]]
name = "v0.1"
title = "Usable in anger"
due = "2026-10-15"
Milestones are the sections of the rendered roadmap. Dated ones sort by date. An
undated one keeps the position it was declared in, by taking the date of the
next dated milestone after it — so an m0-proof written above a dated m1
comes first, while a trailing later stays last.
Saved views
[[view]]
name = "triage"
filter = "milestone=,category!=done"
columns = ["id", "type", "title", "created"]
Used as cairn list --view triage.
Filters
Every query command takes the same grammar. Clauses are combined with commas;
alternatives within one use |; an empty value means unset.
cairn list --filter 'priority=p0|p1,category!=done'
cairn list --filter 'milestone=' # not scheduled yet
cairn list --filter 'labels~auth' # substring or list membership
cairn list --filter 'updated<2026-06-01'
Operators are = != ~ !~ > >= < <=. Alongside your own fields
there are category, blocked, ready, blockers and body for full text.
Hooks
cairn does not embed a scripting language. It runs yours.
[hooks]
after-create = "cairn render -q"
after-change = ["python3", "scripts/notify.py"]
A string goes through the platform shell, which is convenient and therefore platform-specific. An array is executed directly with no shell at all, which is portable — use it in any project that runs on more than one system.
Hooks receive the event in the environment and the complete item as JSON on standard input. They run after the change is on disk, so a failing hook warns and nothing is rolled back.
The render hooks are enabled by default. Keeping the roadmap current is the point, so it is done rather than remembered.