The gardener¶
The gardener is the internal agent (builtin-gardener) that runs
consolidation cycles: duplicate candidates, link maintenance, queue
curation, housekeeping, a neglect report, and — when funded — one LLM job,
abstraction. What those jobs are and why they are cut the way they are is in
Concepts. This page is
the operator's side: what the gardener holds, how to run it, how to give it a
model, and how to see and take back what it did.
Three things are true of it everywhere below. It holds no credential — it
authenticates by being in-process, so there is no token to mint, rotate or
leak. It writes through the same grant-scoped store as an external agent, so a
grant row is the whole of its authority. And everything it infers it files
proposed, even where its grant would let it land live: its suggestions wait
in the review queue like anyone else's.
What it holds¶
A fresh database seeds three grant rows for the gardener. They are ordinary
rows — nodum grants lists them beside every other agent's
(--agent builtin-gardener for just its three), and the Admin page shows them
the same way.
| Space | Level | Why |
|---|---|---|
main |
edit |
Where every write that names no space lands, so where consolidation curates. |
conventions |
edit |
Its own workspace: the curation job's learned conventions are ordinary note nodes here. |
meta |
read |
Types and spaces are nodes in meta; resolving a type is a read of the vocabulary. |
The meta row is read on purpose. No consolidation job writes the type
vocabulary, and edit on meta was tried and rolled back: it bought only
authority no job reaches — creating spaces, renaming main, retitling the
concept type, archiving the note type, after which a human can no longer
write a note. A
grant is a ceiling, and this one is set at what the jobs need.
Widening and narrowing use the commands that already exist:
nodum grant builtin-gardener research edit --as human:owner # let it curate another space
nodum revoke builtin-gardener conventions --as human:owner # stop the convention notes (leave `curation` out of --job to skip the job)
nodum revoke builtin-gardener main --as human:owner # a gardener with no grant does nothing
Every other space is an explicit grant — a cycle scoped to a space the
gardener holds nothing on refuses and prints that nodum grant line rather
than reporting that the space does not exist. An unscoped cycle sees only
what its grants let it see. A revoke takes effect from the next cycle: the
principal is minted when a run starts, so a run already in flight finishes
under the grants it began with, and rollback takes back whatever it wrote
in the meantime.
Changing a level¶
nodum grant re-levels an existing row in place — the same command with a
new level, event-logged with the before and after. The Admin page in the
browser deliberately does not: its picker offers only the spaces the agent
holds no grant on, because re-levelling through an "add" control would be a
real action dressed as a no-op. To change a level there, Revoke the row,
then grant the space again at the new level from the picker that reappears.
The Admin page's Grant control posts to POST /api/grants, the same upsert
as nodum grant.
Running it¶
Cycles run on demand or on a nightly schedule, and only one runs at a time
against a database file — the guard is the journal itself, so a cycle typed
at a terminal while nodum serve is running one is refused, and the refusal
names the cycle in the way.
On demand, from the CLI — rehearse first:
nodum consolidate --dry-run --as human:owner # every job computes, nothing is written but the journal entry (a rehearsal still pays for the abstraction job's model calls when a budget is set)
nodum consolidate --as human:owner # the real thing, every space it holds a grant on
nodum consolidate --scope research --as human:owner # one space
nodum consolidate --job duplicate_candidates --job link_maintenance --as human:owner
--job names are duplicate_candidates, link_maintenance, abstraction,
curation, housekeeping, neglect_report — that is also their run order.
The writes are the gardener's (agent:builtin-gardener); --as names who
asked, which the journal records as triggered_by. In the browser, the
Journal page has the same run panel, with a scope picker and a
Rehearse only checkbox; over HTTP it is POST /api/cycles.
Nightly: set NODUM_CONSOLIDATE_AT to an HH:MM (24-hour, the server
process's local clock — UTC in a container that sets no TZ). nodum serve
runs the cycle in the process it already is, no cron and no second process,
and says so in its startup banner. Unset means off, which is the default; a
value it cannot parse is announced on stderr and ignored rather than
refusing to boot. nodum consolidate on demand works either way.
Giving it a model¶
The deterministic jobs need no language model at all — a cycle runs fine on
a machine that has none. (The embedding-cosine signals inside duplicate and
link inference come from the local embedding model, which is a separate,
key-less thing — see Configuration. It is
manageable from settings.env and the Settings page like the LLM block, and
a model change there blinds every stored chunk to search until the vector
index is rebuilt — the Settings page confirms the change, then offers the
rebuild.) A language
model buys the abstraction job: a proposed concept node synthesised
from a dense cluster, with derived_from edges to its members. That job needs
both models: its cohesion gate is an embedding cosine, so without the embedding
model it reports that it did not run, whatever the LLM block says. Three
switches, and each is off until you set it:
| Variable | Off when | What it turns on |
|---|---|---|
NODUM_LLM_MODEL |
unset — no provider | Any model call at all, for the gardener and for the human-facing ask / summarize / search --nl. |
NODUM_LLM_CYCLE_BUDGET |
unset or 0 — the default |
The gardener's LLM jobs. A cycle with a provider and no budget runs its selection and reports that the abstraction job did not run. The budget is tokens per cycle; NODUM_LLM_CYCLE_SECONDS (default 1800) bounds the same work in wall-clock time. |
NODUM_LLM_API_KEY |
unset — none sent | The bearer key, sent only to an endpoint somebody named: a NODUM_LLM_BASE_URL you set, or a model id nodum ships a profile for. When NODUM_LLM_ENDPOINT selects an endpoint, that endpoint's own NODUM_LLM_KEY_* is sent instead. |
Three shapes cover most installs. The simplest is to pick an endpoint by label and give it its key — both storable, so this whole shape can be done from the Settings page without touching the host:
nodum config set NODUM_LLM_ENDPOINT kimi --as human:owner
nodum config set NODUM_LLM_KEY_KIMI sk-… --as human:owner
nodum config set NODUM_LLM_MODEL kimi-k3 --as human:owner
nodum config set NODUM_LLM_CONTEXT_TOKENS 1000000 --as human:owner # Kimi's window is per-model
nodum config set NODUM_LLM_CYCLE_BUDGET 200000 --as human:owner
The labels are local, deepseek, glm, kimi and openrouter, and
NODUM_LLM_ENDPOINTS narrows that menu for a deployment. Each endpoint has its
own key variable, so changing the selection changes which credential travels
with it — a key can never reach an endpoint it was not entered for. A hosted provider nodum knows is two lines
plus the budget, because the exact model id brings its endpoint, its context
window and its structured-output mode with it:
export NODUM_LLM_MODEL=deepseek-v4-flash
export NODUM_LLM_API_KEY=sk-…
export NODUM_LLM_CYCLE_BUDGET=200000
Any other OpenAI-compatible endpoint names itself, and then nothing about the model id can move a call off it:
export NODUM_LLM_BASE_URL=https://llm.example.com/v1 # scheme included, or it is refused
export NODUM_LLM_MODEL=some-model-id
export NODUM_LLM_API_KEY=…
export NODUM_LLM_CONTEXT_TOKENS=128000 # the window the endpoint actually serves
export NODUM_LLM_CYCLE_BUDGET=200000
The default base URL is a local ollama (http://localhost:11434/v1), which
needs no key. A model id nodum does not recognise, with no base URL of your
own, goes there — and a key set for it is deliberately withheld rather
than posted to a host you did not name. Every variable in the block, with its
default, is in Configuration.
export is one of two ways. Everything above except NODUM_LLM_BASE_URL
and NODUM_LLM_ENDPOINTS can be stored instead, in settings.env beside the
graph:
nodum config set NODUM_LLM_MODEL deepseek-v4-flash --as human:owner
nodum config set NODUM_LLM_API_KEY sk-… --as human:owner
nodum config set NODUM_LLM_CYCLE_BUDGET 200000 --as human:owner
The environment still wins over the file, so a variable already exported is
refused rather than stored where it would never be read — unset it first. What
you gain is that a change applies without a restart: the budget funds the
next cycle rather than the next boot, and a model applies at the next call.
NODUM_LLM_BASE_URL and NODUM_LLM_ENDPOINTS stay environment-only on purpose
— which endpoints a key may travel to is a deployment decision, the first naming
one outright and the second bounding the menu the Settings page offers. The
choice within that menu (NODUM_LLM_ENDPOINT) is storable, because every
endpoint on it is compiled into the build rather than typed into a form.
nodum config list reports which layer each value is in force from.
Then ask what actually resolved, before the first cycle:
It reports configured (from the environment, free) and reachable
(one small call — null means not established, which is very often a live
server still loading a model), the resolved model, context_tokens and
structured_output mode, and api_key_withheld: null when no key is set
or the key is being sent, a sentence naming the endpoint it would have gone
to when it is not. A smart feature that answers false starts here.
In a container, the variables reach the server through the compose
environment: block — the compose example
shows the shape. The key is the one secret nodum reads from the environment:
keep it in an env file (env_file:, or ${NODUM_LLM_API_KEY} interpolation
from one), never in the image and never in a committed compose file. Note the
${VAR:-} form renders an empty value when the host env file says nothing,
and an empty value is unset at every layer — so a key stored in
settings.env is still used, and nodum config list will say so. nodum consolidate and nodum llm status
run inside the container (docker compose exec <service> nodum …) inherit
the same environment.
Reading what it did, and taking it back¶
Every cycle leaves a journal entry — the dream journal, /journal in the
browser, nodum cycle-list and nodum cycle-get <id> on the CLI: what ran,
who asked, what it measured (five coherence metrics, before and after), what
the LLM jobs spent, and how it ended. What it changed is a separate answer,
read off the same append-only log as everything else:
Its duplicate_of and relates_to edges, its concept nodes and its
convention notes are ordinary proposals with an ordinary actor: they sit in
the review queue (nodum review queue --as human:owner, or Review in the
browser) until a human accepts or rejects them, and the curation job's
acceptance rates and per-item annotations are the record it keeps of how that
went — it never accepts and never rejects on its own.
Three verbs sit around a run, and they do different things (the Commands table has them side by side):
nodum cycle-stop <id>— the kill switch for a run in flight. It records that a human asked, and who, and when; the run closes itselffailedat its next check, which sits before every model call — and only there: a cycle that makes no model call (no budget, or the deterministic jobs alone) runs to completion and closescompletedwith the stop recorded on it, which is not a failure. It reverses nothing.nodum rollback <id>— reverses a closed cycle whole, in one transaction, and refuses rather than clobbers when anything outside the cycle has since touched a row it wrote. A rollback is itself a cycle.nodum cycle-abandon <id>— closes the entry of a run that will never finish (a killed process, a power cut), which is what makes its writes reversible again.
undo refuses every cycle-stamped event and names rollback instead: a
curative write is several rows from one decision, and reversing one of them
would leave the other half standing.