This module is the map — what exists and why. Each entry defends a module's
existence; how a module is designed lives in its own @moduledoc.
Ymer Node is the headless local half of one product. It gives an LLM worker
capability that has to live on this machine, while the coordination that has to
be reachable from anywhere lives in ymer. It has no UI of its own and no work
engine, and it never routes work. A client reaches it over MCP at /mcp, on
loopback — pinned by the bind when the node runs directly, and by the host
publish when it runs in a container (YmerNode.Mcp owns that distinction).
The line against a work engine falls here: on its own, the node does one
thing — it runs an accepted script's action on a schedule, with fixed args and
nobody there, a watched reference's fetch among them (YmerNode.Schedules).
It is a script runner with scheduling flexibility, not a reliable scheduler,
and nothing it runs is routed, chained, retried or handed to a model; anything
that needs a task, a claim or an LLM is ymer's.
Two properties shape everything below. Local serving never depends on ymer:
every tool here answers whether or not the node can reach anything, so an
unreachable ymer degrades identity, never capability. And the node owns one
precious thing — notebook.db. Everything else it holds is rebuildable, which
is why exactly one subsystem has a durability obligation. That is the
durability rule, and "rebuildable" is the admission test for anything else
the node stores. node.db and the cache directory are what the rule has
admitted so far. The node database's schema is this codebase's and migrations
run at every boot, so deleting it costs nothing a boot does not put back; the
cache directory (YmerNode.References.Cache.dir/0) holds only what cache
entries in that database name, all of it refetchable: every boot removes the
files no cache entry names and the cache entries whose file is gone, and a
cache entry whose file goes between boots is fetched again at its next read —
so the directory follows the database, and the two are rebuilt together.
Neither is backed up, and "back up the node" goes on meaning the store
alone. The files directory a script reads and writes
(YmerNode.Script.Context.files_dir/1) sits outside the rule altogether: the
node creates it and never touches its contents on its own, so the files there
are the user's — neither precious to the node nor rebuildable by it.
flowchart LR
Mcp[MCP mount]
Notebook[Notebook]
Backup[Notebook/Backup]
References[References]
Scripts[Scripts]
Schedules[Schedules]
NotebookDB[(notebook.db)]
NodeDB[(node.db)]
BackupFiles[(backup files)]
SecretsFile[(secrets.env)]
FilesDir[(files directory)]
CacheDir[(cache directory)]
BrowserService(browser service)
Mcp -->|"serves the notebook tool"| Notebook
Mcp -->|"capture and restore"| Backup
Mcp -->|"serves the references tool"| References
Mcp -->|"serves the scripts and script_author tools"| Scripts
Notebook -->|"the lock's held op, to tell a restore's window apart"| Backup
Notebook --> NotebookDB
References -->|"declarations of accepted scripts"| Scripts
Scripts -->|"reserved names, claimed hosts"| References
References --> NodeDB
References -->|"runs a recipe to fill the cache"| Scripts
References -->|"file cache entries"| CacheDir
Scripts --> NodeDB
Scripts -->|"a run's batteries"| Notebook
Scripts --> SecretsFile
Scripts -->|"a run reads and writes files"| FilesDir
Scripts -->|"a run's browser calls"| BrowserService
Mcp -->|"serves the schedules tool"| Schedules
Schedules -->|"runs an action at each firing"| Scripts
Scripts -->|"the schedule holding a run in flight"| Schedules
Schedules --> NodeDB
Schedules -->|"a watch's firing refreshes an entry"| References
Backup -->|"VACUUM INTO"| NotebookDB
Backup --> BackupFilesThe notebook
YmerNode.Notebook is the raw-SQL surface over notebook.db — a store whose
schema belongs to the user and the LLM, not to this codebase. It owns no
Ecto.Schema and ships no migrations; the agent issues its own DDL. The module
is thin by design: the only behaviour beyond pass-through is the safety model
(a rollback that makes reads provably read-only, and a block on the verbs that
would reach outside this database) and a _meta description layer that gives
SQLite the column comments it lacks.
YmerNode.Notebook.Repo exists to give that store a supervised connection pool
and to load the vendored sqlite-vec extension on every connection, so vector
tables and KNN search are available through the same SQL surface as everything
else. YmerNode.Notebook.VecLoadCheck is the boot probe that turns a silently
failed extension load into a refusal to start.
Backup
YmerNode.Notebook.Backup is a notebook operation, not a subsystem beside it:
the node has exactly one database worth retaining, so "back up" and "back up the
notebook" are the same act. That is a statement about what it owns, not about who
calls it — the tool layer reaches it directly, which is why the map above draws
an edge from the mount and not only through YmerNode.Notebook. The edge the
other way is narrow and one-directional: YmerNode.Notebook reads the lock's
held op to tell a restore's window — the one stopped state that closes on its
own — from every other one, a rule its own moduledoc owns.
YmerNode.Notebook.Backup is deliberately a separate, privileged path from the
agent's SQL surface — it replaces the store whole, which is not something a
prompt-injected agent may reach. YmerNode.Notebook.Backup.Lock serialises
capture against restore.
References
YmerNode.References is the registry: the node's set of references, each a
pointer naming where knowledge lives and when to look, never what it says. It
exists because a worker that re-documents what already lives somewhere has
made a second copy to keep in step, while a pointer and the call that follows
it stay correct on their own. The rule that keeps a reference a pointer while
its content lives in the cache — the membrane — is stated there.
YmerNode.References.Cache is the cache: what each reference's target says,
fetched by running the reference's own script — the node still never makes
an outbound call — and served by the references tool as a window of text,
an image, or a description. It exists so that a reference reads like a local
file while staying a pointer: every entry says how old it is, and how far it
may lag its target is the membrane's. YmerNode.References.CacheEntry is its
row, YmerNode.References.Cache.Window the line window it serves text by,
and YmerNode.References.Cache.Reconcile the boot step that keeps the cache
directory rebuildable.
YmerNode.References.Sources derives a reference's source and fetch recipe
from its uri at read time, against declarations that accepted scripts supply,
so what a reference resolves to follows the node's current capabilities rather
than whatever was true when the row was written.
YmerNode.References.Search is the ranked find over the registry, and owns
the mode contract.
YmerNode.Repo is the node database's repo and the exact counterweight to
YmerNode.Notebook.Repo: this one owns its schema and ships the migrations
that make node.db rebuildable, which is precisely what the notebook's repo
must never do.
Scripts
YmerNode.Scripts is the third capability: small Elixir modules the node has
accepted, compiled into this VM and run on request. It exists because the
node cannot ship a tool for every system a worker needs to reach, and because
the systems worth reaching are different on every machine — a script is how
this node learns to reach one without a release.
The boundary is which scripts are accepted. A run is isolated from crashes and from loops — its own supervised process, its own deadline, killed when the deadline passes — but it is not sandboxed: an accepted script runs with the node's own permissions, reaches the network and the notebook, and could do anything this process could do. Nothing about the run limits that, so the question the design answers is not what may a script do but which code is this node willing to run. The answer is: exactly the bytes someone accepted, and nothing else.
That is the acceptance invariant, and it is one comparison. A row carries
the hash of its code and the hash this node accepted; run refuses unless the
two are equal. Code that changed after it was accepted is code nobody accepted.
Three doors accept today — an MCP client through script_author, a human
through the CLI, and the build, whose example scripts migrations plant on a
fresh node database at exactly the bytes the image carries, because whoever
built the image read them — and each is a deliberate act by someone who can
read what they are landing. A fourth, registry sync, is why the unaccepted
state exists at all before any door can produce it.
YmerNode.Scripts.Script is the row: the code, the two hashes, and the
description and declarations denormalised from the compiled module so a listing
and the references seam are one query each. YmerNode.Scripts.Compiler turns
code into modules and refuses, before compiling anything, a module it can see
defined outside Script. — a guard against the honest mistake, not a second
boundary, because a module body runs while it compiles and the door that
decides what compiles at all is the one above. YmerNode.Scripts.Loader
owns every compile and purge in one process, and holds what this boot knows
about each script. YmerNode.Scripts.Runner is one run: the acceptance check,
the argument check, the deadline, and the shape of every answer.
YmerNode.Script is the contract a script implements and
YmerNode.Script.Context the batteries it is handed — Req, the notebook,
secrets, throttles, the node's time zone, the files directory, the Typst
renders and browser calls — which is why a script needs no dependencies of its own.
YmerNode.Scripts.Throttle is the one battery that is a process: one per
throttle name, shared by every script and run naming it, holding the bucket
and the breaker the account behind the name needs. YmerNode.Scripts.Guide
renders the two, at the call, as the text scripts guide answers: the
contract has one home, a client reads the same bytes a developer reads on the
published docs, and the guide ends with the one thing only the running node
can say — the applications its release carries. YmerNode.Scripts.PackageDocs
renders a promised package's own docs the same way, at the version the
release carries, as scripts info <name> answers: the guide stays a map from
job to package, and the node authors promises and
what a row cannot say, never
library teaching.
YmerNode.Secrets keeps the values a script resolves by name, in a file beside
the databases rather than in one. That is the durability rule holding: a
secrets table would make one row of node.db precious and the rule would have
to grow an exception, where a file leaves it intact and costs a re-set rather
than a restore.
YmerNode.Scripts.BrowserService is the node's half of the browser service, a
program the user runs on their own machine that drives a real browser for a
script's browser calls. It owns where the service is, the token it sends
there, how long a call may wait and how a failure reads, and nothing of the
browser itself: the Chromium builds alone are hundreds of megabytes that most
nodes never use, and Playwright's releases would become the node's. So the
service stays outside the image, and a node without one still answers every
call — with a refusal naming what to run.
YmerNode.Scripts.CLI is the operator's door, run on the machine itself —
where importing a file from a repository and setting a secret that must never
pass through a model's context both belong.
YmerNode.Script.Harness and YmerNode.Script.Test are for a script
developed outside the node, in a repository that takes ymer_node under
runtime: false and so never starts its application. The harness is the
runtime-safe half — the run tree, YmerNode.Script.Harness.run_tree/0, and
setters for the three keys such a VM needs — and YmerNode.Script.Test the
ExUnit layer over it. Neither is rendered into scripts guide: they arrange
a VM around a script, and a script is handed none of their setters. The
guide names one of them only where YmerNode.Script.Context.files_dir/1
refuses an unset directory, as the fix for a VM outside the node.
Schedules
YmerNode.Schedules is how a script runs with nobody there. A schedule is a
standing instruction to run one action of one accepted script, with fixed args,
on a cron expression in the node's time zone, until its
lifetime ends — 90 days at most, because a
schedule nobody remembers must not fire for ever. It exists because a report
wanted at 07:00, or a check every hour, cannot wait for a session to ask, and
one mechanism serves every such need — a watch
too, the schedule a reference owns to keep its cache entry current for a few
hours. A firing runs through YmerNode.Scripts.run/3, so everything the
Scripts section says of a run holds for it. A schedule lives in node.db
beside what it runs — its script, or a watch's reference — and is deleted
with it: it means nothing without that row, so it shares the row's fate under
the durability rule. YmerNode.Schedules.Scheduler is the clock that fires
them.
The MCP surface
YmerNode.Mcp is the mount: the node's entire external surface, and the one
place its tool list is declared. YmerNode.Mcp.McpServer holds the instructions
a client is handed at connect — including the paragraph naming ymer as the other
half of the product, which is the only way a client learns the node is not the
whole story. YmerNode.Mcp.Tools.Notebook, YmerNode.Mcp.Tools.References,
YmerNode.Mcp.Tools.Scripts, YmerNode.Mcp.Tools.ScriptAuthor and
YmerNode.Mcp.Tools.Schedules are the tools themselves, each a thin boundary
over its own context;
YmerNode.Mcp.Tools.Helpers holds the handful of shaping functions their
action layers share, and YmerNode.Mcp.Tools.ScriptFormat the rendering the
two script tools share.
Running and authoring are two tools rather than one so that a client can be
granted the first without the second: what runs is bounded by what has been
accepted, and accepting is the decision worth a human's attention.
The schedules tool is a third for the same reason: a client allowed to run
what has been accepted is not thereby allowed to set it running unattended.
The one exception is bounded: references starts a watch — one reference's
URL action, for twelve hours at most — and the watch is listed and removable
through schedules like any other.