State & storage
Two kinds of state, kept apart on purpose.
Markdown is the source of truth for identity. Personas are meant to be edited by hand and diffed in git.
SQLite is the source of truth for everything with a lifecycle. Sessions, approvals, jobs, the audit trail — none of which anybody wants to read as a file.
The directory
.nouride/ one install, one mounted volume├── config.toml daemon config├── data/ the daemon's own state│ ├── nouride.db SQLite: sessions, turns, cron, approvals, audit, usage│ ├── nouride.db-wal WAL mode — concurrent read and write│ ├── secrets.json 0600, write-only from outside│ ├── .control-token 0600 — what the CLI reads to authenticate│ ├── .session-secret generated if SESSION_SECRET is unset│ ├── credentials/ 0700 — file-shaped credentials, unreachable by file tools│ ├── attachments/<agent>/<date>/│ ├── whatsapp/ Baileys session, AES-256-GCM at rest│ ├── jobs/ background command logs│ ├── tool-output/<session>/ tool results too large for the context│ └── backups/agents/ dated copies of the persona packs├── agents/<id>/ persona packs — git-trackable identity│ ├── IDENTITY.md SOUL.md USER.md AGENTS.md MEMORY.md│ ├── config.toml skills.toml│ └── skills/<name>/SKILL.md├── memories/ captured transcripts, if session memory is on└── workspace/ ├── shared/ every agent — the working directory └── private/<id>/ one agent, unreadable by the othersThree keys point at three of these — data_dir, agents_dir, workspace_dir — rather than one root,
because they differ in backup policy, sensitivity and size. workspace_dir on another disk is a
supported arrangement, and the persona pack is deliberately not a workspace.
SQLite, and nothing else
No Postgres, no Redis, no vector database, no external daemon. One file, WAL mode, opened through Bun’s native driver.
The schema lives in numbered, forward-only migrations compiled into the binary; PRAGMA user_version
tracks what has been applied. There is no migration step to run — a new binary migrates at boot.
What is in there: sessions and their turns, tool executions, approvals, standing grants, sender access, background jobs, cron jobs and their runs, skill drafts, usage and cost, the daemon’s own log, and dashboard accounts.
Every query lives in a *.store.ts file and everything else takes a store, enforced from both
directions by the lint rules. find apps packages -name '*.store.ts' is therefore the complete
inventory of what a move to another database would have to rewrite.
Secrets
Config files hold secret names. Values live somewhere else, and are resolved at the moment they are used:
api_key = "nougate" → $NOUGATE → $NOURIDE_SECRET_NOUGATE → data/secrets.jsonEnvironment wins over the store, so a container platform can always pin a value. The store is what the dashboard writes to, which is why adding a bot token does not need a redeploy.
This is what makes a config.toml safe to commit, print, and render in a browser.
nouride secret # NAMES only — values are never printedprintf '%s' "$TOKEN" | nouride secret set telegram-nouva --value-stdinnouride secret rm telegram-nouva--value <v> exists and the CLI says out loud what it costs: the value ends up in your shell
history. Pipe it instead.
What ages out, and when
[retention]session_days = 30 # conversations, and the attachments belonging to themaudit_days = 30 # the audit trailjob_log_days = 14 # background command outputtool_output_days = 2 # tool results spilled to diskscratchpad_days = 3 # each agent's disposable directoryTwo of these are worth understanding.
job_log_days cannot be “delete when the job ends”, because the agent may still be holding the
path the tool handed it.
scratchpad_days covers workspace/private/<agent>/.scratchpad/ — the disposable directory an
agent is told to put throwaway artefacts in: a screenshot on its way to being sent, a download on its
way to being converted. Deleting inside it is the one delete that is not a hardline class, so an
agent can clear up after itself. It is swept on a timer rather than at the end of the turn, because
the next message is usually about the file that was just sent.
audit_days has a second effect worth knowing: “which skills has nobody read” is derived from audit
rows, so the honest window for that question is however much audit history the daemon still has.
Conversation history
A conversation is stored per agent, per chat, per gateway instance. max_conversation_turns (200)
caps how much is kept.
When the context gets long the daemon compacts rather than truncating: the older part is
summarised and the recent turns are kept verbatim. max_context_ratio (0.8) decides how much of the
model’s window gets filled before that happens.
/new starts a fresh conversation. What survives it: MEMORY.md, USER.md, and the People
entries — the three places a permanent fact belongs.
Session memory
Optional, and off unless active_dir is set. When a conversation ends, the transcript is written to
disk as YYYY-MM-DD-HHMM.md.
It exists because of a real gap: the daemon keeps a conversation in one database column and /new
overwrites it, so on an install with an external memory engine the busiest days were exactly the ones
missing from the vault — the engine indexes dated files, and a conversation that never became a file
was never in one.
The daemon writes the file and stops there. Summarising, indexing and archiving stay the memory engine’s job.
[session_memory]active_dir = "./.nouride/memories"retention_days = 3
[session_memory.reset]mode = "none" # none | daily | idledaily closes a conversation that began before today’s boundary; idle closes one nobody has
touched for a while. That is the whole difference between them: a chat kept up all day is still stale
at the next boundary, and one started an hour ago is not idle however old the chat is.
A capture is dated by when the conversation was last alive, not when the rollover closed it — a chat that ran until 23:00 and idles overnight is closed by the first message next morning, and dating it “now” would file last night’s transcript under today.
Health
[health]heartbeat_interval_ms = 60000disk_warn_threshold_mb = 1024wal_warn_threshold_mb = 100The daemon checks its own free disk on that heartbeat and alerts rather than only logging;
/health reports degraded once the WAL passes its threshold. This is the daemon watching itself —
nothing here wakes an agent. Cron does that, and it is the only thing that does.