All guides
Plugins
A plugin maps the universal core to a consumer's language and policy. Nothing
in the CLI hardcodes vocabulary — it all flows from the active plugin config, chosen once at
nxf init. A plugin sets four things:
- Vocabulary: the words for the two item types and the three statuses.
- Priority labels: the names for priority levels 0–4.
- Ranking: how
nextorders the ready set. - Presentation: which fields
listandshowdisplay.
The two shipped plugins
- issue-tracker — Software issue tracking: epics and issues, P0–P4 priorities, dependency-aware ranking.
- personal-todo — Personal to-do list: lists and todos, now/soon/later priorities for everyday tasks.
Their vocabularies differ:
- issue-tracker: a project is an
epic, a task is anissue; statuses areopen/in progress/closed; priorities areP0–P4;nextranks by priority, then due date, then id. - personal-todo: a project is a
project, a task is atodo; statuses aretodo/doing/done; priorities arenow/soon/later/someday/icebox;nextranks by priority, then id (no due-date tiebreak).
Resolving named variants: types and priorities
type and priority are both named variants — a small, plugin-defined set with a fixed order.
nxf schema --json exposes both as a keyed map, so one rule resolves either field on any item:
schema.types[item.type] # "task" -> "issue"
schema.priorities[item.priority] # "0" -> "P0"The value stored on an item is the handle, not the label. For priority that handle is an
ordinal — "0" is the highest priority, counting up — deliberately plugin-independent so
next can rank numerically and synced items never carry another plugin's labels. The
schema.priorities map is what turns the ordinal back into a human label.
Ordinal-stability contract. Within a schema version the ordinal→meaning binding is stable
and append-only: priority.labels may gain entries at the end, but reordering or inserting
shifts the meaning of already-stored items and is therefore a breaking schema change — a
schema-version bump with migration, not an in-place edit.
Same data, side by side
The seam is clearest when you run the same commands on the same data under each plugin.
Both workspaces below hold three tasks: two at priority 1 (one due 2026-02-01, one due
2026-03-01) and one at priority 2.
next shows two differences at once — the vocabulary and the ranking. Under
issue-tracker, the earlier-due P1 issue (Book venue) wins the tiebreak:
$ nxf next
0002 P1 open [feature] Book venue
0001 P1 open [feature] Draft proposal
0003 P2 open [feature] Order badgesUnder personal-todo, the same two soon todos tie on priority and fall back to id, so the
first-created (Draft proposal) comes first — and the labels are now/soon/later, todo/doing/done:
$ nxf next
0001 soon todo [todo] Draft proposal
0002 soon todo [todo] Book venue
0003 later todo [todo] Order badgesYet the stored record is the same — list --json is id-ordered and does not depend on the active
plugin. The plugin overlays presentation and ranking; it never changes the data. The one field a
plugin owns is type: the type set is plugin-declared, so issue-tracker stores feature where
personal-todo stores todo. Strip that one field and the records are byte-for-byte the same:
$ nxf list --json
[{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Outline the event proposal","design":null,"due":"2026-03-01","id":"ab12.0001","priority":"1","priority_label":"P1","status":"open","title":"Draft proposal","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"},{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Reserve the event space","design":null,"due":"2026-02-01","id":"ab12.0002","priority":"1","priority_label":"P1","status":"open","title":"Book venue","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"},{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Print attendee name badges","design":null,"due":null,"id":"ab12.0003","priority":"2","priority_label":"P2","status":"open","title":"Order badges","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}]show makes the vocabulary difference concrete on a single item. Issue-tracker:
$ nxf show ab12.0002
0002 P1 Book venue
==================
TYPE: feature
STATUS: open
DESCRIPTION
-----------
Reserve the event space
DEFINITION OF DONE
------------------
DESIGN
------
NOTES
-----personal-todo, the same ab12.0002:
$ nxf show ab12.0002
0002 soon Book venue
====================
TYPE: todo
STATUS: todo
WHY
---
Reserve the event space
DONE WHEN
---------
PLAN
----
LOG
---That doubling is not redundancy — it is the explanation. The --json is the meaning; the
plugin is how a particular audience reads and ranks it. Picking a plugin at init is therefore
a real choice; run nxf init --help to see the options before you commit.
Declaring custom fields
A plugin can give its types fields of their own under [fields.<name>]. nxf schema --json lists
them with "custom": true and their kind; create and update set them with
--set <name>=<value>; show --json and the lists return the set ones under custom. An empty
value clears a field.
[fields.stage]
type = "enum"
values = ["backlog", "active", "done"]
on = ["project"] # the types it applies to; omit it for every type
[fields.occurred_on]
type = "datetime"
label = "Occurred on"The type decides what a value may be:
textandlongtext: any text.date: a date (2026-10-12) or an RFC 3339 timestamp, stored as given.datetime: a moment, as an RFC 3339 timestamp with a time zone —Zor an offset such as+02:00. It is stored as the same moment in UTC at whole seconds:--set occurred_on=2026-10-12T10:30:00+02:00stores2026-10-12T08:30:00Z. A plain date or a time without a zone is refused. Because every value has the same shape,datetimevalues sort in time order wherever a field is read, such as a ranking key'sof = { field = "..." }. The offset you typed is not kept.enum: one of the declaredvalues.
List fields
list = true makes a field hold several entries instead of one value — a person's e-mail
addresses, say. Each entry is a value of the field's type (text, date, datetime or enum,
checked as above) and may carry a label of your own, such as work or private:
[fields.email]
type = "text"
list = true
on = ["person"]nxf entry add <id> email ada@work.example --label work
nxf entry add <id> email ada@home.example
nxf entry list <id> email
nxf entry remove <id> email ada@home.example
nxf update <id> --set email= # clears the listshow --json and the lists return the entries under custom, in the order their values were
first added: "email": [{"value": "ada@work.example", "label": "work"}, ...], label only where
there is one. nxf schema --json marks the field "list": true. Over MCP the verbs are
flow_entry_add, flow_entry_remove and flow_entry_list.
The value is the entry: adding a value the list already holds gives that entry the new label
(or none, without --label) instead of adding it twice, and removing a value removes it
whatever its label. Two devices may add and remove at the same time: an address added on one
survives a remove on the other that never saw it, and both end with the same list. --set only
clears a list field; a list field cannot be required, a longtext, take a merge, or rank.
entry add and entry remove print the entry as stored, so a datetime entry given as
10:30:00+02:00 reads back as 08:30:00Z, and a datetime entry is removed by the same moment at
any offset. Every entry you can see can be removed: entry remove checks neither the value against
the field's type (an enum value since dropped) nor on (a type the plugin has since left out). A
list field's name is at most 64 characters.
Ref fields
type = "ref" makes a field a typed reference to another item — the organiser of a meeting, the
company a person works at. Its value is an item id, and targets names the item types it may
point at. A ref is a single value or, with list = true, a list of entries with optional labels:
[fields.organizer]
type = "ref"
targets = ["person"]
on = ["meeting"]
[fields.participants]
type = "ref"
targets = ["person"]
list = true
on = ["meeting"]nxf update <meeting> --set organizer=<person>
nxf entry add <meeting> participants <person> --label host
nxf entry remove <meeting> participants <person>
nxf update <meeting> --set organizer= # clears it--set and entry add take a full id (ab12.0007, or a foreign cd34.0001) or a bare local
one (0007), which is resolved as every id you type is. They check the target, in this order: the
value is an id, the item exists and is not deleted, its type is one of targets, and it is not
the item itself. A closed or archived item is a valid target. entry remove checks nothing, so a
ref to an item since deleted can still be removed. The type and the self-reference are checked when
the value is written, never after: retyping or deleting the target later leaves the ref as it is.
show --json keeps the value under custom and adds refs, which resolves each target:
"refs": {"organizer": [{"id": "ab12.0007", "found": true, "type": "person", "title": "Ada", "status": "open"}]}, or {"id": …, "found": false} for a value that names no item on this board.
The human show prints each target's id and title under the field's heading, or (not on this board). nxf schema --json reports the field as "kind": "ref" with its "targets".
A ref cannot take merge = "crdt-text" and cannot rank; its name is at most 64 characters.
Redeclaring a text field as a ref keeps its values: the ones that are ids become references, the
rest show as not on this board. A ref is written with the ordinary field ops, so a replica started
from a snapshot stays valid.
Finding the referrers
nxf list --ref <field>=<id> lists the items whose ref field holds the id: a single value equal to
it, or an entry equal to it. It is a list filter, so it composes with --type, --status,
--in, --label, --sort, --limit and --paginate; a bare local id is resolved. Repeat it for
alternatives (at most 8). With a plugin that declares meetings, stations and relationships:
nxf list --type meeting --ref participants=<person> # the meetings of a person
nxf list --type station --ref person=<person> # the stations of a person
nxf list --type relationship --ref a=<person> --ref b=<person> # relationships, from both endsUnder --sort rank the filter narrows the rows, never the ranking: the items come in the order the
unfiltered ranked list gives them. The id need not name an item — the referrers of a deleted item
stay findable, and an id nothing holds lists nothing. Deleted referrers are dropped, closed ones
listed; archived referrers are not listed, because list lists live, unarchived items
(nxf archived takes no --ref). An item whose own id is longer than 128 bytes is never a
referrer. A field that is not a ref of the plugin, a value that is not an id, --ref without
=, and more than 8 refs are refused. list --json is unchanged; over MCP, flow_list takes
refs: [{"field": "participants", "id": "<person>"}].
Declaring a ranking
A plugin declares how next ranks under [ranking.next] and how list --sort rank ranks under
[ranking.list]. Both take the same keys and the same rules:
order = [...]is the default order: a list of keys, compared in turn, with the id as the final tiebreak.[ranking.next.<type>](or[ranking.list.<type>]) gives one type its ownorder.types = [...]says how the types stand against each other once some have their own order.
A key is a field key, such as { field = "priority", dir = "asc" }. With a precedence list,
a field key ranks by the value's position in that list instead. In a per-type order, or in the
[ranking.list] default order, a key can also be a derived key, which ranks an item by its
neighbours in the graph:
[ranking.next]
types = ["project", "action"]
order = [ { field = "priority" } ]
# related: a value read on the neighbours over one outbound edge (parent, contributes_to, dep,
# mentions). `of = "rank"` reads the neighbour's position in its own type's order;
# `of = { field = "..." }` reads a field, a custom field included. `pick` is min (default) or max.
[ranking.next.action]
order = [
{ related = "parent", type = "project", of = "rank", nulls = "last" },
{ field = "priority" },
]
# count: how many neighbours match. children, contributors, dependents and mentioned_by count the
# inbound parent, contributes_to, dep and mentions edges; an outbound kind counts the other way.
# `type`, `status` (a list) and `label` filter the counted neighbours.
[ranking.next.project]
order = [
{ related = "contributes_to", type = "goal", of = { field = "order" }, nulls = "last" },
{ count = "children", type = "action", status = ["open", "in_progress"], dir = "desc" },
]
# sum: several counts, added with equal weight. `dir` belongs to the sum.
[ranking.list]
types = ["person"]
[ranking.list.person]
order = [
{ sum = [ { count = "mentioned_by", type = "project" },
{ count = "mentioned_by", type = "action", label = "waiting" } ], dir = "desc" },
]Derived keys count only live neighbours: a deleted one is gone, a closed or archived one counts.
A rank is computed over the type's candidates under the verb that ranks it, and items that tie on
every key share a rank. For next these candidates are its actionable items; for list they are
the live, unarchived items. A neighbour outside that set, such as a closed project under next,
has no rank and reads as null. A list key may read the rank of a type that only next ranks;
next's candidates are then taken at the current time (NXF_NOW pins it). Only list --sort rank
reads that time.
The plugin is refused at load, with the key named, when:
- a key mixes kinds or names none;
- a key names an unknown edge kind, type, status or field;
- a key carries a part its kind does not take, such as
pickon a count ornullson a count or sum (a count is never null); of = "rank"names a type without an order of its own;- the types rank by each other in a cycle.
A related ... of = "rank" key without type reads the rank of every type with an order, its own
included. That is a cycle, so name the neighbour's type; the [ranking.list] default order
refuses such a key too. Derived keys are not allowed in the
[ranking.next] default order, because that order also ranks blocked and the lanes'
--sort rank.