Nodus - Product Design Document
Version: 0.4.25 Date: 2026-04-11 Status: Active Development
Executive Summary
Nodus is a local-first knowledge workspace where research nodes, Typst math, and Obsidian vaults live on a single, sovereign canvas.
The Problem We Solve: Users are experiencing "Visual Burnout" and "Subscription Fatigue." They manage fragmented windows — a Notion page embedding a Miro board, a Zotero library separate from their notes, screenshots of diagrams that can't be edited. The doc and the whiteboard are never the same thing.
Our Solution: A "Living Documentation" workspace where everything is a node on one canvas. No embeds. No dead images. No context switching.
Core Differentiators: - Single Canvas: The doc and the whiteboard are the same thing - Modern Science: Native Typst (sub-second rendering, not 90-second LaTeX) - Eco-Bridge: Seamless Obsidian vault compatibility - EU Sovereignty: Local-first + zero-knowledge EU sync
Target markets: Academic researchers and EU enterprises Revenue model: Open core — free local app, paid EU-hosted sync
Market Context (2026)
The State of "Second Brain" Tools
Users are frustrated with the current landscape:
| Tool | User Criticism |
|---|---|
| Notion | "Frame Manager" — canvas is just embeds, data doesn't talk to each other |
| Miro | No offline, not knowledge-focused, separate from notes |
| Heptabase | $12-18/mo with no free tier, proprietary database lock-in |
| Obsidian | Graph is view-only, mobile app is "clunky," Canvas is separate from Notes |
| Logseq | Database version delays, performance degrades as vault grows |
| LaTeX | 90-second compile times, 1984-era syntax |
What Users Are Actively Searching For
-
"Living Documentation" A single space where a paragraph can connect via visual arrow to a task or PDF highlight. The doc and whiteboard as ONE thing.
-
"Sovereignty as a Budget Item" EU AI Act (2026) enforcement means institutions cannot use US-hosted clouds for sensitive research. "GDPR-Native" is a procurement requirement.
-
"LaTeX-to-Typst Migration" Instant-preview math that looks professional enough for a thesis but feels as fast as Markdown.
-
"Agent-Ready Data" Users want to point Ollama at their notes. They criticize proprietary formats that trap data away from local AI.
The Gap We Fill
| User Complaint | Nodus Solution |
|---|---|
| "I'm paying $15/mo for proprietary cloud" | Free local app + open formats (Markdown/JSON) |
| "LaTeX takes forever to compile" | Typst integration (sub-second rendering) |
| "My university won't let me use Notion" | EU-hosted sync + local-first privacy |
| "My Obsidian Canvas is messy/slow" | Graph-first UI with native auto-layout |
| "I paste diagrams as dead images" | Editable, linkable nodes on canvas |
| "My tools don't talk to each other" | Single canvas, everything is a node |
Positioning
The Name: Nodus
Nodus (Latin: "node" / "knot") — the fundamental unit of connection.
Manifesto
"In a world of scattered tabs and cloud-locked docs, Nodus is your anchor. It's the local-first node-based editor that treats every link as a discovery. With native Typst support and a deep bridge to your Obsidian vault, Nodus is the professional choice for those who think in systems. Your data, your device, your network."
What NOT To Say
"We are a graph app" "We are a note-taking app" "We are an Obsidian alternative"
What TO Say
"Stop managing windows."
Nodus is the only workspace where your research nodes, your Typst math, and your Obsidian vault live on a single, sovereign canvas.
The "Aha!" Moment
The moment a user realizes: - Their Zotero citation appears as a node they can drag next to their argument - Their PDF highlight becomes a linked card on the canvas - Their LaTeX equation renders instantly in Typst - Their Obsidian vault imports with beautiful auto-layout
This is not another tool to manage. This is where thinking happens.
Product Vision
Core Concept
A "Living Documentation" workspace where: 1. Every piece of information is a node 2. Nodes live on an infinite canvas 3. Connections are visual arrows, not hidden backlinks 4. The canvas IS the editor — no separate views
The "Single Surface" Principle
| Traditional Tools | Nodus |
|---|---|
| Note in one app, diagram in another | Everything on one canvas |
| Screenshot a diagram → "dead image" | Diagram is editable nodes |
| Embed a board inside a doc | No embeds — canvas IS the doc |
| Switch between graph view and editor | Graph view IS the editor |
| Canvas file separate from notes | Canvas and notes are unified |
Guiding Principles
- Single Canvas: Doc and whiteboard are the same thing
- Markdown-First: Standard GFM for data longevity, no vendor lock-in
- Typst-Powered: Modern, fast math and professional PDF export
- Obsidian-Compatible: Use existing vaults without breaking them
- Local-First: Data lives on user's device by default
- Agent-Ready: Structured data that local AI (Ollama) can consume
- EU-Sovereign: Zero-knowledge sync on EU infrastructure
Target Users
Primary: Academic Researchers
Profile: - PhD students, postdocs, research faculty - Literature review, concept mapping, thesis writing - Privacy-conscious (unpublished research)
Current Pain: - LaTeX is slow; Zotero sync is clunky - University restricts US cloud tools - Obsidian Canvas feels like afterthought
Our Solution: - Typst speed + native Zotero-to-Canvas - EU-hosted sync meets institutional requirements - Graph-first UI where the graph IS the note
Willingness to pay: EUR 8-15/month individual, EUR 200-500/year institutional
Secondary: EU Enterprises
Profile: - Knowledge workers, consultants, strategists - Compliance-sensitive industries (legal, finance, healthcare) - GDPR-conscious organizations
Current Pain: - US cloud risks (Schrems II, EU Data Act) - Tools scan data for AI training - No self-hosted options for sensitive work
Our Solution: - Local-first + EU E2E sync (Hetzner/OVH) - Zero-knowledge encryption (not a data processor) - Self-hosted enterprise option
Willingness to pay: EUR 15-25/user/month, EUR 5K-20K/year enterprise
Tertiary: Visual Thinkers / Privacy Enthusiasts
Profile: - "Second brain" power users - ADHD/non-linear thinkers - Privacy-first individuals
Current Pain: - Cloud apps scan data for AI training - Graph views are read-only afterthoughts - Subscription fatigue
Our Solution: - Local LLM (Ollama) + local-first storage - Graph-first UI - Free tier with no artificial limits
Architecture
Local-First with Optional Sync
+------------------+ +-------------------+ +------------------+
| Desktop App | | EU Sync Server | | Other Devices |
| (Tauri) |<--->| Zero-Knowledge |<--->| (Desktop/Web) |
| SQLite local | | E2E encrypted | | |
+------------------+ +-------------------+ +------------------+
|
v
+------------------+
| Local LLM |
| (Ollama) |
+------------------+
Why Local-First?
- Privacy: Data never leaves device unless user opts in
- Performance: No network latency for core operations
- Offline: Full functionality without internet
- Compliance: Not a "data processor" — simplifies GDPR
- Agent-Ready: Local AI can access all data without cloud roundtrip
Sync Architecture (Future)
- CRDT-based for conflict-free merging (planned; no CRDT library is wired up yet)
- Zero-knowledge E2E encryption — server cannot read content
- EU-hosted infrastructure (Hetzner, OVH, Scaleway)
- Self-hosted option for enterprises
The Bridge: Obsidian Compatibility
Why This Matters
Obsidian users are the primary migration target. They already: - Have large vaults (100s-1000s of notes) - Value local-first architecture - Are frustrated with Canvas being separate from notes
Strategy: "The Bridge" (Not Migration)
Users don't abandon Obsidian — they enhance it with Nodus.
File Watcher: Monitors local folder (Obsidian Vault) Database Mapping: SQLite indexes Markdown while storing canvas metadata Bi-directional: Edits sync both ways
Mapping
| Obsidian Element | Nodus Implementation |
|---|---|
.md file |
A node entry in SQLite |
| Folder path | A tag (keeps canvas flat) |
[[Wikilink]] |
A row in links table |
| YAML frontmatter | Parsed into tags/type |
.canvas file |
Convert to our coordinates |
attachments/ |
Link via local file paths |
Initial Import: Auto-Layout
The "Empty Canvas" Problem: If 1,000 notes land at (0,0), users quit in 5 minutes.
Solution: Force-Directed Layout algorithm on import - Clusters related notes based on wikilinks - Respects folder structure as grouping hint - User can choose: "Explode" (spread out) or "Cluster" (tight groups)
Conflict Resolution
- Text: Last Write Wins
- Canvas position: Nodus exclusive (Obsidian doesn't care about x,y)
Timelines: Design Decisions
| Decision | Rationale |
|---|---|
| Only dated nodes are placed | Positions are facts, not guesses; no interpolation, no sequence fallback. Undated nodes simply do not appear |
Dates live in frontmatter (date:, date_end:) |
Files stay the source of truth; Obsidian/OKF compatible; the in-app date editor (node card chip, preview panel) writes the same fields |
| Broken axis | Large empty stretches between event clusters are abbreviated with a marked break; gap detection compares against both span share and median spacing so even spreads never fragment |
| Per-segment label detail | Each axis segment labels itself at its own scale — years, months, days, or clock times — so a one-hour narrative and a millennium can share one axis |
| Plot fills its container | The axis is laid out to the measured width of the plot column, not a fixed one. A fixed 980px plot was wrong at every width except by coincidence: in a narrow window it overflowed into a horizontal scroll with the last tick pressed against the border, and in a wide one it left dead space to the right. The fixed width remains the fallback for when no measurement is available, so the axis never collapses |
| Bottom sheet, sized to content | Timelines slide up from below (spatial model: storylines right, time below, graph above), opened from the toolbar, only as tall as the lanes need, capped at 45% |
| Coexists with overview and reader | The sheet stays open alongside the storyline overview and under a shortened reader; left-edge pushes still step back through reader and overview but never close the sheet |
| Closes with an upward push | The sheet opened by moving down, so it closes by moving up: a push into the top edge band closes it, and so does the pointer leaving the window through the top region - an upward motion usually exits into the title bar before any pointermove lands in the narrow band. The same applies to every edge: a window leave counts as a push on the edge it left through, with horizontal exits taking precedence over vertical ones, because a fast flick reaches the desktop before any sample lands inside a 12px band. Mirrors the spatial model (time below, graph above); the left edge is reserved for stepping back through the storyline layers |
| Unassigned lane | Dated nodes outside every storyline get a neutral gray lane so the timeline shows every dated node in the workspace |
| Marks colored by node, lanes by storyline | Color follows the entity; node colors are solidified from their canvas background tints. Uncolored storylines get a stable hue from a colorblind-validated palette |
| One hover tool | Beads drive the canvas's own hover tooltip through the shared external-hover events; no second preview implementation |
Wikilink Sync Strategy
Wikilink edges are maintained incrementally. Each node stores the content hash it was last wikilink-synced at (wikilink_synced_hash); the full sync pass on startup and workspace switch skips every node whose hash is unchanged, touching neither the filesystem nor the edge table for them. Links whose target does not exist yet are recorded in pending_wikilinks (source node, normalized target key) and resolved the moment a node with a matching title or path is created or renamed — so a dangling [[link]] becomes an edge without waiting for a full re-scan. The file watcher remains the primary mechanism for reacting to file edits; the full pass is a safety net that is now cheap when nothing changed.
Open Knowledge Format (OKF)
Nodus interoperates with Google Cloud's Open Knowledge Format (OKF v0.2), the Markdown-plus-frontmatter spec for agent-readable knowledge bundles:
- Bundle export: A workspace exports to an OKF bundle — concept documents with
type/title/tags/generatedfrontmatter grouped by node type, a rootindex.mdwithokf_version: "0.2", and wikilinks rewritten as bundle-relative Markdown links. The source workspace and vault are untouched. - New files: Note files that Nodus itself creates carry OKF frontmatter, so vaults grown inside Nodus converge toward OKF conformance without rewriting pre-existing user files.
- Both agent surfaces state the format. The in-app agent's prompt and the MCP server's tool documentation name OKF, because a model that does not know the target format writes content that has to be corrected afterwards. Naming it once, in the same words on both surfaces, is what keeps the two from drifting.
- Backfill: existing vault files that lack frontmatter can be brought to OKF in place. The operation adds a frontmatter block and never touches the body, so wikilinks and prose survive unchanged - the Obsidian bridge is a pillar, and OKF is a specification of Markdown with frontmatter, not a replacement for it. A file that already has a frontmatter block is left alone rather than merged into, because merging risks losing fields the user set by hand.
Rendering: Markdown + Typst
The "LaTeX-to-Typst" Migration
Users are desperate to escape LaTeX's 90-second compile times.
| Aspect | LaTeX (1984) | Typst (2023) |
|---|---|---|
| Compile | 30-90 seconds | Sub-second |
| Syntax | \begin{equation} |
$ x^2 $ |
| Debug | Cryptic errors | Clear messages |
| Setup | 2GB TeX Live | Few MB WASM |
Implementation
Markdown for prose (GFM standard) Typst for math and export (WASM in-app)
# Research Note
The integral is: $ integral_a^b f(x) dif x $
```typst
#table(
columns: (1fr, 1fr),
[Variable], [Value],
[Alpha], [0.5],
)
### Streaming responses
**Required behavior:** A response that arrives as one buffered body is indistinguishable from a stalled connection while the model is still generating, and gateways cut it. Streaming keeps bytes flowing.
- Generation requests ask the provider to stream, and the backend forwards each chunk to the interface as it arrives rather than accumulating the whole body.
- A provider or endpoint that does not stream still works: the response is read whole, as before.
- A stream that ends mid-message is an error, not a short answer. Silently returning a truncated generation would corrupt the text it was cleaning.
### Localisation
The application ships five locales. Every user-facing string comes from a locale file, and every locale carries every key.
Two failures were possible and both happened. A key added only to `en.json` falls back to English silently, so 29 strings - the whole MCP settings panel among them - appeared in English to anyone using another locale with nothing to indicate a translation was missing. And a component with no `useI18n` at all cannot be translated whatever the locale files contain: the canvas context menu, the reader footer, the entity sidebar and four more were written entirely in literal English.
A gate holds the first: every locale has every key English has, no locale carries a key English lacks, and no translation is an empty string. Product names and language names in a picker stay untranslated, because that is what they are called in every locale.
### Removing dead code
Nothing is exported that nobody imports. Two shapes count as dead: a value referenced from nowhere at all, and a value exported while only its own file uses it. Both make a reader believe an interface exists, and both are how a fix comes to be applied to something nothing reaches.
Types are exempt. An exported type describes a module's shape and is worth declaring whether or not another file names it.
A function that only a test names is not dead: testing a unit directly through its export is how it should be tested, and forcing the test through a wrapper to satisfy this rule would be worse. The gate checks references from any file, tests included.
### One rule, one place
A rule is written once. Where the same rule existed in several places, the defect was always the same: a fix landed in some copies and not others.
- The frontmatter check existed three times, and one copy did not recognise CRLF.
- The availability probe existed four times, and three asked the wrong endpoint.
- The trash move existed three times, and the copies disagreed on what a failure means.
- The hidden-entry filter existed twice, and one did not exempt the vault root.
- The storyline duplicate guard existed three times, and all three ignored the storyline.
A review reads files one at a time, so it finds instances and not the class: seeing that two functions should be one requires holding both at once. Two gates do that instead. No two functions may share a body, and no exported name may carry more than one implementation - an exported name is a contract, while a local handler may share an ordinary name like `run` or `animate` without meaning anything.
### Lookups that cannot be made
An outside service that could not be reached has not answered. Every lookup keeps the two apart:
- A negative answer is a value. `null` from a paper lookup means the service says no such paper exists; an empty list means it says there are no references; an empty set of library identifiers means the library was read and holds none.
- An inability to ask throws. The caller then decides what to do, instead of receiving a value that reads as a successful negative.
Two consequences this prevents. Duplicate checking against a library that could not be read reported "no duplicates" and created them. A citation lookup during an outage recorded a paper as citing nothing, which is a claim about the paper rather than about the network.
A partial result is treated the same way: a paged lookup whose later page fails throws rather than returning the pages gathered so far, because a partial list cannot be told from a complete one.
### Reporting MCP server failures
Starting the MCP server can fail for an ordinary reason: the port is already in use. The settings panel says so, and the stored "enabled" flag records what the user asked for and got, so a failed start is not retried on the next launch.
Three things had to be true before any of that could happen, and none were:
- The panel's `error` was a computed returning `null`, so the error row could never render.
- `toggleServer` awaited the start with no catch, so a failure surfaced only as an unhandled rejection.
- The error row sat inside the "server is running" block, which is never true when a start fails.
### Stopping the server
Stopping reports success when the server has stopped, not when it has been asked to stop.
The command sent on the shutdown channel and returned, while the flags that say whether a server runs, and on which port, were cleared later by the task that observes that message. A status read straight afterwards still reported the old port as running. A start issued straight afterwards, which is what a toggle off and on does, was refused as "already running" by a server that was already shutting down, and the settings panel then believed a server was up that had just gone.
### Provider status
**Required behavior:** The status light beside a provider must report whether the application can get an answer from it, because that is the only thing the user consults it for. Probing a different endpoint from the one the work uses - a model listing rather than a completion - reports a route that can succeed while every real request fails on authorisation, gateway routing or timeout, and a green light next to a failing provider is worse than no light at all.
- Availability is tested with a minimal request of the same kind the application makes: the completions endpoint, one token.
- A provider that answers is online. A provider that refuses, times out, or cannot be reached is offline, whatever its model listing does.
- The reason a check failed is kept and shown, since "cannot be reached" and "refused the key" call for different fixes from the user.
Every provider answers the question the same way, through one shared probe:
- The endpoint is the one used for work, with the **configured** model. A model listing answers whenever the server is up, including when the configured model was never pulled or the account cannot reach it. Probing a hardcoded model tests something the user never chose.
- Any failing status means unavailable. Reading "any status other than 401" as online showed green beside a model that answered nothing.
- The reason is recorded on the provider and declared on the interface, because "could not be reached", "refused the key" and "no such model" call for different fixes. Reaching into one implementation's field with a cast left the other three reporting nothing.
### PDF text cleanup
**Required behavior:** Cleaning up extracted PDF text is a long generation - the model rewrites everything it is sent - and a request whose response takes minutes is cut by the idle timeout of any gateway between the application and the model. The failure looks like an unreachable endpoint while the endpoint is answering other requests in milliseconds.
- Responses are streamed. Tokens arriving continuously keep the connection active, so a generation of any length cannot be mistaken for an idle connection and cut. This is the fix; section size only limits how much is lost when something else fails.
- Text is cleaned in sections small enough that a lost section costs little, rather than in the largest sections the context window allows.
- A section the model does not return is imported as extracted; the sections around it keep their cleanup. Cleanup improves the text, so losing it must never cost the text itself.
- The node says how many sections were imported as extracted, and why, so the result is not silently worse than it looks.
### Reading files the user dropped
**Required behavior:** Commands that read a file refuse paths outside a workspace vault, so that a path invented by an agent or arriving over the MCP connection cannot read arbitrary disk. A file the user drags onto the window is the opposite case: they chose it themselves, and the files worth dropping - a paper in Downloads, a PDF in Zotero's storage - are almost never inside a vault.
- A path the user dropped on the window is readable for the rest of the session, whether or not it lies in a vault.
- The grant comes from the operating system's drop event as the backend receives it, never from a path handed over by the interface. A caller that can name a path could otherwise grant itself access to it, which is the check this guard exists to make.
- Everything else is unchanged: a path that was neither dropped nor inside a vault is still refused.
### File paths across platforms
**Required behavior:** Windows separates path components with a backslash, macOS and Linux with a forward slash. Code that splits on `/` alone turns a dropped `C:\\Users\\dana\\paper.pdf` into a node titled with its whole path. One helper extracts the file name, and it accepts both separators. A gate test fails on any source that splits a path on `/` alone, and on any multi-select or modifier check that accepts `metaKey` without `ctrlKey`: the Command key does not exist on Windows or Linux, so a Command-only check is a feature those users cannot reach.
### Drop position
**Required behavior:** A file dropped on the canvas lands where the cursor released it. The drop event's position arrives in physical pixels on Windows and Linux but in logical pixels on macOS, while the canvas works in logical pixels throughout. Dividing by the display scale factor on macOS therefore halves the coordinates and places the node up and left of the cursor on any HiDPI display. The conversion is platform-aware and covered by unit tests per platform.
### PDF as a graph
**Required behavior:** A paper is already a structure - sections, an argument, a bibliography - and flattening it into one node discards exactly what a graph tool is for. Dropping a PDF offers a choice of how it lands:
| Mode | What is built | Needs |
|------|---------------|-------|
| Single node | The whole document in one node, as before | Nothing |
| Section graph | One node per top-level section - headings deeper than two levels fold into their parent's node, so a paper becomes its chapters, not every sub-subsection - edges following the document tree, each tagged with the paper's title | Nothing - structural, deterministic |
| + References | Entries in the references section become citation nodes with `cites` edges from the paper | Nothing to parse; a lookup service to verify |
| + Semantic graph | An LLM pass per section extracts claims and findings as nodes with typed edges (`supports`, `contradicts`, `related`) | The configured language model |
- The section graph and references never depend on the LLM: they must work offline and when the model is down.
- **Verification states are three, not two.** A parsed reference checked against the lookup service is `verified` (found), `not_found` (the service answered and has no match), or `not_checked` (the service could not be reached). An outage must never mark a reference as missing: someone else's downtime must not invalidate the user's bibliography. The state is stored in the citation node's frontmatter and shown on the node.
- References with a DOI are checked by DOI; those without are matched by title. A title match below the service's own confidence is `not_found`, not a guess.
- **Zotero is opt-in per import.** When the Zotero integration is configured, the import dialog offers to add the extracted references to Zotero; nothing is written without that choice. Verified references carry their resolved DOI into Zotero.
- The semantic graph is a choice in the same dialog, never a default: it spends model time and its quality depends on the model. Sections whose extraction fails are skipped with a notice, and the structural graph is never held up by it.
### PDF highlights as nodes
**Required behavior:** A researcher's reading already happened somewhere else. The highlights in a PDF are the parts they judged worth keeping, so re-typing them onto the canvas is work they have already done once.
- Dropping a PDF that carries highlights offers them for import instead of importing them silently. Which passages are worth a node is the reader's judgement, not the application's.
- Each imported highlight becomes a node holding the highlighted passage and the reader's own comment if there is one, linked to the node created for the source document. The link is what makes the passage traceable back to what it came from.
- Highlight colour is carried onto the node, because readers who colour-code assign meaning to the colours.
- Import is additive: a highlight that has already been imported is not imported twice.
**Known limitation:** a highlight can only be imported when the PDF stores the highlighted text with the annotation, which annotators such as Zotero and Acrobat do and some, notably macOS Preview, do not. Recovering the text for the rest means locating it geometrically in the page content stream, which is not implemented. Highlights whose text cannot be recovered are reported as such rather than imported as empty nodes.
### Printing the canvas
**Required behavior:** "Print to PDF" produces a picture of the canvas, where document export produces a text. It prints the selected nodes, or every node of the workspace when nothing is selected, and not merely the nodes on screen.
- One page, sized to the bounding box of the printed nodes plus a margin, with one canvas unit as one point. A fixed paper size would shrink a large graph until nothing could be read; a page of the graph's own size is printed or scaled by whatever opens it.
- The page is vector: text stays sharp and can be searched and copied.
- Each node is drawn as a card at its stored position and size, whatever the zoom level or bubble mode shows, with its title and as much of its content as fits; the rest is clipped, as on the canvas. A tag node is drawn with its title alone.
- Only edges with both ends among the printed nodes are drawn; an edge to a node that is not printed would point at nothing. An edge is a straight line from border to border, with an arrowhead when it is directed and a label when it has one. Routed paths are not used: they avoid nodes that may not be printed, and they exist only for the part of the canvas that is on screen.
- No interaction state is printed: no selection border, hover highlight, dimming, grid or overlay. Colours are those of the light theme, with a node's own colour as its tint, whichever theme is active. A card whose colour is dark gets light text. The typeface is the one the compiler carries, a serif, not the canvas typeface.
- Content is printed as text. Headings, bold, italic, code and list items keep their formatting; quote markers, rules and the separator row of a table are left out; math, images, diagrams and table rows appear as their source.
- The page is compiled, by the backend compiler that document export uses, before the save dialog opens, so a page that cannot be produced leaves no file behind.
- The action is in the context menu of a node, for the nodes the menu acts on, and in the canvas controls, for the selection or else for all nodes.
### Document export
**Required behavior:** Everything in Nodus moves thinking onto the canvas; export is the only path that takes finished work back off it. Without it a completed argument has to be retyped somewhere else to become a document, which is where the tool stops being useful and the user goes back to a word processor.
Two formats, one code path: the Typst source is generated first and PDF is that source compiled by the Typst compiler in the backend, the one that renders math, with the fonts it bundles. The compiler that runs in the web view is not used for PDF: it could not be loaded there, and it fetches its fonts from a content delivery network that the application's content security policy does not allow and an offline machine cannot reach, so every PDF export failed. A test that replaced the compiler with a stub reported it as working; the backend compiler is tested on real source.
| Format | Purpose |
|--------|---------|
| PDF | A finished document to send or submit |
| Typst source | A starting point to keep editing outside Nodus |
Scope is whatever the user is looking at when they ask:
- **A storyline** exports in storyline order. The sequence is the argument the user built; reordering it by canvas geometry would destroy the one thing that makes a storyline a document.
- **A selection** exports in reading order - top to bottom, left to right - because a loose set of nodes has no order of its own.
The export dialog collects title, author, paper size and whether to append the connections between exported nodes. The backend then opens the save dialog and writes the file, so the chosen path never round-trips through the interface: a path the interface names is not a path the user chose, and only the second may be written to. This mirrors how dropped files are granted for reading - from the operating system's own event rather than a caller-supplied string. Compilation failures are reported in the dialog; a PDF that failed to compile must never be written as an empty or partial file.
### "Modernize My Math" Import
On Obsidian import:
- Detect LaTeX math (`$\frac{a}{b}$`)
- Offer to convert to Typst syntax
- Immediate visual improvement
---
## UX: The Canvas
### The "Living Documentation" Experience
Everything is a node. Nodes can be:
- Text (Markdown)
- Math (Typst)
- Citation (from Zotero)
- PDF highlight
- Image
- Task
- Person
All nodes:
- Exist on the same canvas
- Can be connected with visual arrows
- Can be grouped by tags
- Are editable in place
### Editing Philosophy: "Inline-First, Modal-Second"
Users want the node on the canvas to be the "source of truth." They dislike clicking a node and having a sidebar that feels like a different app.
**Principle:** The ability to double-click a node and start typing immediately *inside* that box, with the box expanding to fit text.
**The Hybrid Sweet Spot:**
- **Inline editing** for content (the default)
- **Modal/sidebar** only for metadata (tags, properties, file path) or long-form writing (>500 words)
### Node Interaction States
| State | User Action | System Response |
|-------|-------------|-----------------|
| **Idle** | Click node | Highlight node, show contextual toolbar (color, link, edit) |
| **Quick Edit** | Double-click | Enable inline `textarea` within the canvas node |
| **Long Edit** | `Cmd/Ctrl + Enter` | Open note in modal/pane for distraction-free writing |
| **Connect** | Drag from edge | Draw connection line to target node |
| **Move** | Drag node | Update canvas_x, canvas_y in real-time |
### Semantic Zooming
**Problem:** 500+ nodes become "dust" when zoomed out.
**Solution:**
- Zoom out: Nodes aggregate into clusters, show only titles
- Zoom in: Content reveals, full editing mode
**Edge label sizing (required behavior):** Edge labels are rendered in canvas
coordinates inside the zoom-scaled layer, so a fixed canvas font shrinks as the
user zooms out and grows as they zoom in. To keep labels legible, the rendered
font is counter-scaled by `1/zoom` so labels hold a roughly constant on-screen
size. The divisor is clamped to a 0.2–3× zoom window so labels neither balloon
when zoomed far out nor collapse when zoomed far in; the base size still comes
from the user's `edgeLabelSize` canvas setting.
**Edge label zoom threshold (required behavior):** Analogous to the semantic
zoom threshold for node content, edge labels have their own zoom threshold.
When the viewport zoom is below the threshold, edge labels are not rendered;
at or above it, they render normally. Because labels are counter-scaled, they
would otherwise stay full-size while nodes collapse, dominating the zoomed-out
view. The threshold is a display setting (`edgeLabelZoomThreshold`, range 0–1,
default 0.5) configurable via a slider in Settings > Appearance next to the
semantic zoom threshold; setting it to 0 keeps labels visible at every zoom
level.
**Hover tooltip content (required behavior):** Hovering a node while zoomed out, or a bead on a timeline, opens a tooltip with the node's title, its edge counts and the start of the note. The note is shown as rendered Markdown, and its vertical spacing comes only from the block margins the tooltip defines for paragraphs, headings and lists. Line breaks in the rendered HTML are not content: the renderer emits one between every pair of block elements, and a container that preserves whitespace turns each into an empty line, which enlarged every gap and reduced how much of the note fits in the tooltip's fixed height. Whitespace is preserved only in the plain-text fallback shown when no rendered content exists, where line breaks are the only structure the text has. The tooltip is styled in its component alone; a gate test fails if another stylesheet declares rules for the tooltip's own elements or if the rendered-content container preserves whitespace.
**Pan and zoom cost (required behavior):** Panning and zooming change the viewport on every frame, and the culling result feeds everything downstream - the rendered node list and all edge styling. The culled result therefore keeps its identity while the same nodes are on screen: a frame that moves the viewport a few pixels returns the previous array and set, so no dependent recomputes. A new result is produced only when a node actually enters or leaves the viewport, or when the node set itself is replaced (new objects must never be served from the cache). Enforced by tests that pan without changing visibility and assert referential stability, on both the linear-scan and spatial-index paths.
**Panel sizing (required behavior):** Every panel the user can resize uses one composable, so the drag behaviour, clamping and persistence cannot drift between them: the storyline overview (width), the agent panel (width) and the timelines sheet (height). Each grows in the direction that points away from its edge - a right-hand panel widens as its separator is dragged left, a bottom sheet grows taller as it is dragged up - within a clamped range, and the chosen size is stored per panel and restored on the next launch. The timelines sheet keeps sizing itself to its lane count until the user drags it, after which their height wins. While a separator is being dragged the panel's own slide transition is suspended, for the same reason the canvas overlays suspend theirs: a transition lags behind the pointer.
**Tooltip placement (required behavior):** A tooltip is placed by measuring, never by a rule written per container. The previous approach - a default direction plus an override for each edge-anchored container - required whoever added a control to predict whether its label would fit, and every container that nobody thought about clipped its tooltips at the window edge. That is a defect the mechanism produces, not one its users forget to prevent.
- One tooltip element for the whole application, positioned from the trigger's measured rectangle and its own measured size.
- The preferred side is below the trigger; when the tooltip would leave the viewport it flips to the opposite side, and if neither side fits it is clamped to stay fully inside. Placement therefore cannot depend on which container the trigger happens to sit in.
- An element may request a preferred side, but the request is honoured only when the result stays on screen. A preference that would clip is overruled.
- Tooltip text always comes from the locale files - a literal string cannot be translated.
- Gate tests hold both rules: the placement function must return a rectangle inside the viewport for a trigger at any position including every corner, and no stylesheet may position a tooltip through a `[data-tooltip]` pseudo-element again.
### Canvas Features
- Infinite pan/zoom
- Manual node positioning (drag to place)
- Double-click canvas → create node
- Double-click node → inline edit
- Drag between nodes → create connection
- Node auto-resizes to fit content
- **Multi-directional resize:** All edges and corners (8 handles)
- Minimap navigation (tucked into the canvas's top-right corner)
- **Undo/Redo system:** Full support including node deletion with edge restoration
- **Cmd/Ctrl+Click:** Zoom-to-fit on specific node (auto-scales based on node size)
- **External links:** Open in default system browser
- **Context menu:** Right-click for node actions (fit, storyline, send to workspace, delete)
- **Copy/Paste nodes:** Cmd+C/Cmd+V to copy and paste nodes (preserves layout)
- **File drop import:** Drag files directly onto canvas (see File Drop Import below)
**Canvas overlays and the storyline panel (required behavior):** Overlays anchored to the canvas's right edge (minimap, zoom controls) are offset by `--canvas-right-inset`, the width of whatever storyline layer covers the canvas, so they stay beside it instead of underneath it. That offset animates on the shared step easing when a layer opens or closes by an edge step, but the animation must be suppressed while the user drags the panel's separator: a transition makes every overlay lag behind the pointer and rubber-band after the drag stops. The panel exposes its drag state, App publishes it as `--inset-duration` (`0s` while resizing), and every overlay that reads `--canvas-right-inset` must time its transition with that variable - enforced by a gate test that scans the stylesheets.
### File Drop Import
Drag and drop files directly onto the canvas to import them. Supported formats:
| Format | Extension | Import Behavior |
|--------|-----------|-----------------|
| **PDF** | `.pdf` | Extract text, clean up with AI, create note node(s) |
| **Markdown** | `.md` | Create note node with content |
| **BibTeX** | `.bib` | Parse citations, create citation nodes |
| **CSL-JSON** | `.json` | Parse citations, create citation nodes (Zotero export) |
| **Ontology** | `.ttl`, `.rdf`, `.owl`, `.jsonld` | Import RDF graph as nodes and edges |
**PDF Import:**
- Extracts text from PDF using Rust backend
- Splits long documents into multiple connected nodes
- AI cleanup: fixes OCR errors, rejoins broken paragraphs, formats as markdown
- Shows progress indicator during processing
**Ontology Import:**
- When ontology files are dropped, a modal appears with options:
- Create nodes for classes
- Create nodes for individuals/instances
- Nodes are auto-laid out after import
- RDF properties become edges between nodes
**Ontologies split across files:** An ontology is often published as several files that refer to each other's classes (OBOE: `oboe-core`, `oboe-characteristics`, `oboe-standards`). Importing them one at a time must give the same graph as importing them together.
- A relation's ends are looked up in the import and among the nodes already in the workspace, matched by the URI each class or individual note records. A `subClassOf` from a class in this file to a class imported earlier becomes an edge.
- A relation whose end is not in the workspace yet is kept as pending for that workspace, and becomes an edge when a later import brings that end in. Import order does not matter.
- A class or individual already in the workspace is not created again; re-importing a file adds only what is missing, and an edge that already exists is not duplicated. Re-importing is therefore how a workspace imported before this behaviour gets its missing edges.
- The importer skipped every relation whose end was outside the file being imported. Importing OBOE's three files separately left 266 `subClassOf` relations without an edge and 217 of the workspace's 299 nodes without any edge (measured 260930).
### Zotero Integration
Two methods for bringing citations in from Zotero:
**Method 1: File Drop (Export/Import)**
1. In Zotero: Right-click collection → Export Collection → CSL-JSON (Better BibTeX recommended)
2. Drop the `.json` file onto the Nodus canvas
3. If collection metadata detected, ImportOptionsModal appears with options:
- Optionally tag the citations with the collection name
- Import attached PDFs (future)
- Layout choice (grid/force)
4. Citation nodes created in a grid
**Method 2: Zotero Web API (Settings)**
1. Settings → Zotero → enter the Zotero user ID and an API key, then test the connection
2. Import one collection, or all items, into the open workspace as citation nodes
3. Selected citation nodes can be added to the library as new items ("Add to Zotero"); an item whose DOI is already there is skipped
Both directions are single actions: nothing is synchronised, an existing Zotero item is never changed, and only the personal library is reached. The steps are in `features.md` > Zotero Integration.
**Zotero takes citation nodes (required behavior):** "Add to Zotero" accepts citation nodes and citation stubs, and nothing else: a note that mentions a DOI is a note, not a reference. The menu offers the action when the selection holds at least one such node and adds those; the tool names every other node it was given as not a citation node and adds the rest. The rule sits in the one function behind the menu action and both tools.
Until this rule the action took any node carrying a DOI, because the papers that "Fetch Citations" and "Fetch References" bring in from Semantic Scholar were created without a type and stored as notes. In the database the rule was measured on, 2080 nodes were such notes against 202 nodes of type citation, so restricting the action first would have refused nine in ten of the nodes it existed for. Fetched papers are therefore created as citation nodes, and a migration converts the existing ones: a note whose frontmatter carries `semantic_scholar_id`, a field only the Semantic Scholar import writes, becomes a citation node. Nothing else about the node changes, and none of the 2080 had a file, so no file is touched. The migration finds nothing to do on a later start.
**Adding to Zotero from an agent (required behavior):** The `add_to_zotero` tool takes node ids and adds each named node to the library as a new top-level item. It is the context-menu action under another name: both call one function, so their rules cannot differ. A node whose DOI is already in the library is skipped and counted as a duplicate, a node without content is skipped, nothing is added when the library cannot be read, and no existing item is changed or deleted. An id that names no node is reported in the result and affects nothing else. The tool returns the counts of added, duplicate and skipped nodes with any errors, and the app shows the toast the menu action shows, so an addition made by an agent is visible to the user. It needs an API key with write access. The tool exists on both agent surfaces: the MCP server, where a connection scoped to a workspace names nodes of that workspace, and the in-app agent in execute mode.
**Supported Formats:**
- CSL-JSON (Zotero native, Better BibTeX extended)
- BibTeX (.bib)
**Better BibTeX Extensions:**
- `citation-key` field for custom citation keys
- `collections` array for collection names
- `attachments` array for linked PDFs
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Delete` / `Backspace` | Delete selected nodes/edges |
| `L` | Force layout (D3-force) |
| `N` | Toggle neighborhood mode |
| `P` | Toggle physics mode |
| `F` | Fit to content |
| `Shift+R` | Reset all node sizes to default |
| `Shift+E` | Export graph as YAML (debug) |
| `Cmd/Ctrl+A` | Select all nodes |
| `Cmd/Ctrl+C` | Copy selected nodes as JSON |
| `Cmd/Ctrl+V` | Paste nodes from clipboard |
| `Ctrl+Shift+R` | Refresh workspace from files |
### Toolbar
**Required behavior:** No workspace name, however long, may push the toolbar's controls off screen.
- A `<select>` takes the intrinsic width of its widest option. A workspace named "Lorenz workshop - Beyond Models: Sustainable AI Infrastructure as a Scientific Instrument" stretched the selector across the toolbar and pushed the search box and the icons past the right edge of the window.
- The name is data, so no care in the markup prevents it: the control carries a maximum width. It still opens at full width when clicked, so long names stay readable where it matters.
- The search box is centred in the window. The toolbar is a three-column grid whose two side columns share the remaining width equally; centred in the space between the left and right groups, as before, it sat off-centre by half the difference of their widths. Where the left group is wider than its share, its column grows and the search box moves right rather than overlapping it.
### Context Menu
Right-click on a node to access:
| Action | Description |
|--------|-------------|
| **Fit to Content** | Auto-resize node to fit its content |
| **Add to Storyline** | Add node(s) to existing or new storyline |
| **Send to Workspace** | Move node(s) to a different workspace |
| **Delete** | Remove node(s) from canvas |
Multi-selection: All context menu actions work on multiple selected nodes.
**Bubble mode:** A right-click on a bubble opens the same menu as a right-click on a card, and a right-click on empty canvas closes it. The bubble canvas reports which node was pressed, since a bubble is a painted circle and not an element; the viewport around it answers only the right-clicks the bubble canvas has not answered. Treating every right-click that did not land on a card element as a click elsewhere closed the menu in the same event that had opened it, so in bubble mode it never appeared.
**Placement:** The menu opens at the pointer and is kept inside the window. When it does not fit below or to the right of the pointer it opens above or to the left instead, so a right-click on a node near the bottom or right edge shows the whole menu rather than running off screen. A menu taller than the window is pinned to the top edge, because the first item must be reachable.
### Physics Mode
**Required behavior:** Physics mode lets the graph arrange itself while the user watches and pulls on it. A layout command computes positions once; physics mode keeps a force simulation running on the canvas, so dragging a node drags its connections along and the rest makes room.
- The mode is off by default and toggled from the canvas controls or with `P`. It is not remembered across sessions: where a node sits is the user's decision, and a simulation that starts on its own would move nodes nobody asked to move.
- It runs in bubble mode only. Cards are DOM elements with routed edges, and moving hundreds of them every frame re-runs card layout and the edge pipeline each time; bubbles are circles and straight lines on one 2D canvas. Leaving bubble mode switches the mode off and stores the positions.
- The simulation covers the nodes on screen when the mode is switched on, at most 2000 of them; above that the toggle is disabled and says so. Nodes off screen that are connected to them stay fixed and act as anchors, so the visible part keeps its place in the whole graph. An edge to an anchor is a tether: it keeps the length it has when the simulation is built, and at least the length of an edge between simulated nodes, so the anchor holds its neighbour where it is and pulls it back when it is dragged away. Given the common edge length instead, an anchor reeled its neighbour in from any distance: on a graph of 309 nodes with three anchors 80 000 units away, one run moved the simulated nodes a median of 3100 units toward them and left the nodes in between strung across the canvas. Switching the mode off and on again takes the nodes on screen at that moment.
- Forces: edges pull connected nodes together, nodes within 1200 units repel each other, and nodes do not overlap. There is no centring force and repulsion does not reach further, so distant nodes do not nudge each other.
- The graph does not drift: forces between simulated nodes rearrange them and never carry them off as a whole. The edge force moves the two ends of an edge by unequal shares, the end with fewer edges further, and the collision force does the same by size, so neither cancels out over the graph. The remainder pushed the whole graph a few hundred units in one direction on every reheat - switching the mode on, a grab, a node or edge added by an MCP client - which accumulates over a session and carries the graph away from the nodes that were off screen. The net push is removed on each step: from the edge force for every connected group that contains no anchor and no held node, and from repulsion and collision over all nodes, since a push between two nodes is equal and opposite whichever of them is fixed. The edges of a group held by an anchor or a held node keep their net pull, since that is the fixed node pulling on it.
- A dragged node follows the pointer and stays pinned while held; the simulation reheats and the others respond. Releasing the node lets it move again. The held node stays the hovered node until the pointer is released. A simulated node is painted a frame or two after the pointer moves, so the pointer runs ahead of the circle; with hover read from the pointer, hover ended and began again several times a second, and each change switched the dimming of every other edge, showed and hid the tooltip and, above the edge hover threshold, showed and hid the edges themselves. The release ends the hold wherever it lands: pressing a bubble selects it, which opens the preview panel, and the panel keeps pointer releases to itself, so a release over it never reached the canvas and the node stayed hovered, its edges thick and highlighted, until another node was pressed and released elsewhere.
- The simulation runs in a Web Worker, so a slow step never blocks input. Each step returns the node centres as one flat array (`Float64Array`, x and y per node), and the bubble canvas paints circles and straight edges from that array directly: no store write, no reactive update and no edge routing happens while the nodes move. One step is requested per painted frame, so the simulation never runs ahead of the display.
- Positions are written to the store once, for the nodes that moved, when the simulation comes to rest or the mode is switched off; edges are routed again at that moment. Switching the mode on records one undo step, so one Undo restores every position from before the session.
- With snap-to-grid on, stored positions are snapped; the simulation itself runs unsnapped.
- Anything else that moves nodes while the mode is on - a layout command, Undo, a change from another window - ends the mode without storing the simulation's positions. The simulation holds its own copy of every position and the bubble canvas paints that copy, so a layout run during the mode changed the store but nothing on screen moved, and switching the mode off then stored the simulation's copy over the layout.
- A change to the graph while the mode is on - a node or an edge added or removed, by an MCP client, the agent or the user - is taken into the simulation. The positions reached so far are stored, the simulation is rebuilt from the nodes on screen and their edges as they now are, and it reheats, so a new node settles among its neighbours and a new edge pulls its ends together. No further undo step is recorded: one Undo still restores the positions from before the session. Creating or deleting a node also counts as a position write, which used to end the mode like a layout does, discarding what the simulation had reached; a change of the graph now takes precedence over that rule. A change that leaves more than 2000 nodes on screen ends the mode and stores the positions.
- The mode is unavailable in neighbourhood mode, whose positions are an overlay that is never stored.
### Neighborhood Mode
Focus view that isolates a node and its connected neighbors:
- **Depth control:** Configurable 1-5 hops (edges away from focus node)
- **BFS traversal:** Finds all nodes within specified depth
- **Layout:** The subgraph is arranged by the same layout algorithms the canvas uses, not by a placement of its own. Entering the mode arranges it radially around the focus node, which suits a focus view and keeps a hub with many neighbours on screen: the radius grows with the neighbour count rather than a row growing with it.
- **Changing the layout:** While the mode is active, the grid, force, hierarchical and radial controls apply to the visible subgraph rather than the whole canvas. The subgraph is the whole scope of such a run: the selection that put the canvas into the mode does not narrow it. Only a radial run reads the focus node, as its centre - handed to the others, which read a selection as "lay out only these", it made them arrange the focus node alone and leave the subgraph as it was.
- **Positions are not stored:** Every arrangement computed in this mode is an ephemeral overlay. Stored coordinates are untouched, so leaving the mode restores the canvas exactly as it was.
- **The minimap follows:** it shows the subgraph on screen at its overlay positions, not the workspace behind it.
- **Visual highlighting:** Focus node and neighbors highlighted, rest dimmed
- **Reading a node:** Selecting a single node opens its preview panel wherever the card does not show the content - cards collapsed to their titles, or bubbles - exactly as outside the mode. The mode had been excluded from that rule, so in a zoomed-out neighbourhood a click selected a node and showed nothing of it. Double-click keeps its meaning of moving the focus.
- **Moving the focus:** Double-clicking any neighbour card, on its title or its body, collapsed or expanded, makes that node the focus and re-arranges the subgraph around it. The title's double-click-to-rename is suspended for neighbours while the mode is open, because the header's rename handler used to stop the event before the card saw it: a double-click on a neighbour's title opened the rename input, with the title selected, instead of navigating. Renaming by double-click stays available on the focus node and outside the mode.
### Edge Routing (PCB-Style)
Edges are routed using a motherboard/PCB-inspired lane system:
| Parameter | Value | Purpose |
|-----------|-------|---------|
| `STANDOFF` | 80px | Minimum distance from node edge before routing |
| `LANE_WIDTH` | 12px | Spacing between parallel edge lanes |
| `PORT_SPACING` | 25px | Spacing between connection ports on same side |
**Routing algorithm:**
1. Analyze edges and determine exit/entry sides based on relative positions
2. Assign ports on each node side, sorted to minimize crossings
3. Route each edge through dedicated lanes using GridTracker
4. Obstacle avoidance via spatial indexing
**Edge styles:**
- `straight`: Direct line between nodes
- `orthogonal`: 90-degree turns only (default)
- `diagonal`: Angled routing with obstacle avoidance
- `curved`: Smooth Bezier curve
- `hyperbolic`: S-curve that exits/enters nodes orthogonally
**Live routing during drag and zoom (required behavior):**
While a node is being dragged or the canvas is being zoomed, edges must
re-route on every animation frame so they follow the moving node and keep
their configured style. This is intentional, not a performance oversight:
freezing the routing (reusing the pre-drag cached paths) leaves edges
stationary until the drag ends, and substituting cheap straight/orthogonal
fallback paths makes the edge style visibly "pop" during the drag. Both were
tried and rejected as regressions.
Implementation note: `useEdgeRouting` recomputes `routeAllEdges` when the
routing key changes **or** while `isDragging`/`isZooming` is true, and commits
the routing key only when not deferring, so the final positions are routed once
more when the interaction ends. Do not "optimize" this by skipping the
per-frame recompute during drag/zoom without preserving both the live-follow
and the styled appearance. If per-frame routing ever becomes a measured
bottleneck on very large graphs, gate any simplification behind a node-count
threshold rather than applying it to all graphs.
**Routing while a layout moves the nodes (required behavior):** A layout animation moves every node on every frame, which a drag does not. Routing every edge in its style on each of those frames cost 117 ms per frame at 300 nodes in the orthogonal style (36 ms after the routing work under Routing cost), so a layout reached its end in one or two frames and looked like a jump. While a layout animation is moving nodes, edges are drawn as direct lines between the cards; when the motion ends they are routed once in their style. A drag keeps live styled routing as described above. Physics mode does not route while it runs at all (Physics Mode).
### Routing cost
**Required behavior:** Routing runs on every frame of a drag, a zoom and a layout animation, so its cost decides whether those move smoothly.
- The lane tracker and the spatial index key their cells by number, not by building a string per cell. Each segment walks every 12 px cell it crosses, often several times while a free lane is sought, and every long edge queries the index over hundreds of 200 px cells; creating a string per cell cost more than the routing itself.
- Obstacle checks along an edge take their candidates from the spatial index instead of scanning every node.
- None of this changes a route: a test fingerprints the routes of generated graphs in every edge style and requires them unchanged.
- Measured with 300 nodes and 500 edges, orthogonal style: 84 ms per full route before, 36 ms after (260930). Timing is not gated by a test, because it varies with machine load; it is still over the 16 ms of a frame at that size, so a layout animation on a few hundred visible nodes still drops frames.
### Grid layout cost
**Required behavior:** The grid layout (up to 500 nodes; above that a simpler fast grid is used) packs cards tightly and keeps connected cards close. Its search considers, for each card, positions next to every placed card and in the gaps between pairs of them.
- Each candidate position was tested for overlap against every placed card, and every candidate was built before most were thrown away. The cost grew with roughly the fourth power of the node count: 1.3 s at 300 nodes, 9.5 s at 500, about 100 s at 800 (measured 260930), and the canvas waited that long before anything moved.
- Overlap is now tested against the cards in the same grid cells only, candidates are tested as they are generated, and distances to connected cards are summed over those cards only: 0.27 s at 300 nodes, 1.1 s at 500.
- No card moves as a result: a test fingerprints the layout of generated graphs and requires it unchanged. The remaining cost is the search over pairs of placed cards, which decides where gaps are filled.
### Themes
YAML-based theme system with SQLite storage. Four built-in themes, plus LLM-generated custom themes.
| Theme | Background | Use Case |
|-------|------------|----------|
| `light` | Light gray | Default daytime |
| `dark` | Dark zinc | Evening work |
| `pitch-black` | True black | OLED displays |
| `cyber` | Neon accents | Aesthetic preference |
**Custom Themes:** Users can ask the graph agent to create themes:
- "Create a crazy bananas theme" generates YAML with custom colors
- Themes stored in `themes` table with workspace association
- Theme YAML defines CSS variables, effects, and metadata
**Theme Schema (YAML):**
```yaml
name: "custom-theme"
display_name: "Custom Theme"
is_dark: false
variables:
bg_canvas: "#f4f4f5"
bg_surface: "#ffffff"
text_main: "#18181b"
primary_color: "#3b82f6"
Data Model
Design Decisions
- TEXT IDs (UUIDs): Required for CRDT sync compatibility. Auto-increment integers would conflict across devices.
- Checksum column: SHA-256 hash of file content to detect external changes from Obsidian.
- Typst cache: Pre-rendered SVG for 60fps canvas performance.
Core Schema
-- 1. Nodes: The fundamental unit of the graph
CREATE TABLE nodes (
id TEXT PRIMARY KEY, -- UUIDv4 generated by Rust
title TEXT NOT NULL,
-- Content & Source
file_path TEXT UNIQUE, -- Path to local .md file (NULL if not mapped)
markdown_content TEXT, -- Raw markdown for inline editing
node_type TEXT DEFAULT 'note', -- note, task, citation, pdf, etc.
-- Spatial Metadata (Nodus exclusive)
canvas_x REAL DEFAULT 0.0,
canvas_y REAL DEFAULT 0.0,
width REAL DEFAULT 300.0,
height REAL DEFAULT 200.0,
z_index INTEGER DEFAULT 0,
frame_id TEXT, -- Always NULL: frames are removed (see Frames removed)
-- Styling & State
color_theme TEXT, -- 'default', 'blue', 'red', etc.
is_collapsed BOOLEAN DEFAULT 0,
tags TEXT, -- JSON array
workspace_id TEXT,
-- Sync & Version Control
checksum TEXT, -- Hash of content to detect external changes
created_at INTEGER, -- Unix timestamp
updated_at INTEGER,
deleted_at INTEGER, -- Soft delete
FOREIGN KEY(frame_id) REFERENCES frames(id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
);
-- 2. Edges: Visual connections between nodes
CREATE TABLE edges (
id TEXT PRIMARY KEY,
source_node_id TEXT NOT NULL,
target_node_id TEXT NOT NULL,
label TEXT, -- Optional edge label (e.g., "cites", "blocks")
link_type TEXT DEFAULT 'related',
weight REAL DEFAULT 1.0, -- For layout algorithms
created_at INTEGER,
FOREIGN KEY(source_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY(target_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
UNIQUE(source_node_id, target_node_id)
);
-- 3. Frames: removed. The table stays, empty, as the target of
-- nodes.frame_id's foreign key (see Frames removed)
-- 4. Typst Cache: Stores rendered SVG for performance
-- Prevents re-compiling math every time canvas moves
CREATE TABLE typst_cache (
node_id TEXT PRIMARY KEY,
raw_typst_code TEXT, -- The math formula or Typst block
rendered_svg TEXT, -- The SVG string to render
compiled_at INTEGER, -- When last compiled
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
-- 5. Canvas Views: Saved view states
CREATE TABLE canvas_views (
id TEXT PRIMARY KEY,
workspace_id TEXT,
name TEXT,
zoom REAL DEFAULT 1.0,
pan_x REAL DEFAULT 0.0,
pan_y REAL DEFAULT 0.0,
filter TEXT, -- JSON: visible types/tags
updated_at INTEGER,
FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
);
-- 6. Workspaces
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
vault_path TEXT, -- Obsidian vault path
created_at INTEGER
);
-- Indexes for performance
CREATE INDEX idx_nodes_filepath ON nodes(file_path);
CREATE INDEX idx_nodes_workspace ON nodes(workspace_id);
CREATE INDEX idx_nodes_type ON nodes(node_type);
CREATE INDEX idx_edges_source ON edges(source_node_id);
CREATE INDEX idx_edges_target ON edges(target_node_id);
CREATE INDEX idx_frames_workspace ON frames(workspace_id);
File Watcher Logic (Obsidian Bridge)
The Rust backend uses the notify crate to watch the Obsidian vault:
| Scenario | Detection | Action |
|---|---|---|
| File unchanged | Checksum matches | No action |
| File edited externally | Checksum differs | Update markdown_content and updated_at |
| File edited in Nodus | After save | Update file on disk AND checksum (prevent loop) |
| New file added | No matching file_path |
Create new node, run through parser |
| File deleted | file_path exists, file gone |
Soft delete node |
| Node renamed in Nodus | After the title save | Rename the file to the new title's file name; the watcher's checksum entry is moved to the new path first, so the removal and creation it then sees are not events |
A file moved outside Nodus keeps its node: the watcher records the new path. If recording it fails, the user is told, rather than the node pointing at a path that no longer exists.
A node renamed in Nodus takes its file with it. The file name follows the rule used for files Nodus creates, so the same title yields the same name on both paths. The content is not rewritten and the checksum travels with the file. The rename is skipped, and the node keeps its path, when sync is off for the file, when the new title maps to the current name, or when another file already holds the target name; the frontend learns the outcome from the title command's answer, which carries the new path or nothing. Renaming the file naively would have been read back as a deleted note and a new one: the move detection matches on file name, which a rename changes by definition.
Edges are reloaded once per burst of external changes, not once per file: the reload follows the last change of a burst by 300 ms. A reload replaces the edge set, so the canvas re-routes and redraws every edge; when an agent or a sync tool rewrote 150 notes at once, reloading after each file ran that 150 times, and each running Nodus instance kept several cores busy for minutes. Stopping the watcher cancels a reload still waiting.
Reading a file and its checksum together
Required behavior: The checksum stored against a node is the checksum of the content that node holds. Both come from one read.
- The handler read the file itself and stored the checksum carried by the watcher event. Those describe two different moments: a write landing between the event and the read stores a checksum for content the node does not hold, and the node then looks reconciled while it is not - the next event for that file matches the stored checksum and does nothing, so the difference never resolves.
- One backend call returns the content and the checksum of the same bytes. The checksum is taken over the raw bytes, as the watcher takes it, so the two remain comparable for a file that is not valid UTF-8.
- Applying content newer than the event announced is correct, not a race lost: it is the state of the file, stored with its own checksum, and the event for that newer write then finds nothing to do.
A save that does not reach the vault
Required behavior: When a node is backed by a file in a vault that syncs, a save that did not reach that file is reported to the user. Silence is reserved for saves that were never meant to touch a file.
- The backend writes the file only when the node has one, it exists, and a workspace with sync enabled covers it; otherwise it updates the database alone and returns no checksum. The frontend read that as an ordinary save, so a node whose file was missing, moved, or outside its vault kept taking edits that went nowhere near disk.
- A write that throws was caught into a log line and nothing else. The node kept the new text in memory, so the edit looked saved.
- Either case now raises a notification naming the node. The database still holds the edit - nothing is lost at the moment it happens - but the vault copy is behind, and the next external change to that file would replace the newer text with the older.
- Fifteen nodes in one workspace reached this state before it was noticed: text written to the database on one day, files untouched for five months.
Reconciling a file with the node open in the editor
Required behavior: An external change to a file is never written over an editor that is open on that node. The editor's copy is what the user is looking at and typing into; replacing it under them destroys text no undo step covers.
- While a node is being edited, a
Modifiedevent for its file leaves the node alone and says so once. The node keeps the checksum it had, so the difference is not forgotten: the next write settles it, and that write is normally the user's own save, which carries the editor's text to the file. - The guard is the editor's own state, handed to the watcher as a dependency. The watcher cannot reach into the canvas to ask, and the editor cannot know a file changed.
- Without it, the handler read the file and pushed it into both the store and the database unconditionally. A stale file - one the database had already moved past - therefore replaced newer text with older, which is exactly how a save appears to come back old.
Saving from the canvas editor
Required behavior: Saving a node from the canvas keeps its frontmatter and ends the edit. The editor shows the body only, so the save is what puts the metadata back.
- The editor splits the frontmatter off when it opens and joins it back onto the body when it saves. The body alone is never written to the store, the database or the file.
- Every way of leaving the editor goes through one save path: clicking away, Escape, and Cmd/Ctrl+Enter.
- Leaving the editor clears the editing guard. The guard stops the watcher from writing a file over an open editor, so a guard left set after the editor closes makes the watcher ignore that node's file for the rest of the session.
Two save functions existed, and the canvas was wired to the one that did neither. Every canvas save dropped the frontmatter from the node and its database copy, and left the guard on the last edited node.
Typst Rendering Workflow
- User types
$E=mc^2$in a node - Node content updated in SQLite
- Rust detects math block, calls Typst WASM compiler
- Resulting SVG stored in
typst_cache - Canvas reads from cache for 60fps rendering
Node Types
| Type | Purpose | Source |
|---|---|---|
note |
Information, ideas | User created |
task |
Actionable items | User created |
citation |
Academic reference | Zotero import |
pdf |
Document | File import |
highlight |
PDF annotation | PDF viewer |
person |
Contact | User created |
topic |
Concept cluster | User/auto |
Link Types
| Type | Meaning | Visual |
|---|---|---|
related |
General association | Gray arrow |
cites |
Academic citation | Blue arrow |
blocks |
Dependency | Red arrow |
supports |
Evidence | Green arrow |
contradicts |
Opposition | Orange arrow |
Feature Roadmap
Phase 1: Canvas + Obsidian Bridge
Goal: "Living Documentation" foundation
- [x] Infinite canvas with pan/zoom
- [x] Semantic zooming (aggregate on zoom-out)
- [x] Node CRUD on canvas
- [x] Visual connections (drag to link)
- [x] Inline editing
- [x] Frames for grouping (removed 260930, replaced by tags)
- [x] Obsidian vault import
- [x] Auto-layout algorithm (D3-force)
- [x] Bi-directional vault sync
- [x] Wikilink → link parsing
- [x] Minimap
- [x] Keyboard shortcuts
- [x] Undo/redo system with deletion support
- [x] Multi-directional node resize
- [x] PCB-style edge routing
- [x] Neighborhood mode with depth control
- [x] Theme system (4 themes)
Phase 2: Modern Researcher
Goal: Zotero-to-Canvas as the "Aha!" moment
- [x] Zotero integration (core pillar)
- [x] Citation node type
- [x] Drag citation → create linked node (BibTeX/CSL-JSON drop)
- [x] Zotero collection → Frame mapping (collection → tag since 260930)
- [x] Direct Zotero library access (Settings > Citations)
- [x] PDF import with highlights
- [x] PDF highlight → canvas node
- [x] Typst math rendering (WASM)
- [x] Live-rendered equations
- [ ] "Modernize My Math" import
- [x] Export to Typst
- [x] Export to PDF (journal-quality)
- [ ] LaTeX export (legacy support)
Phase 3: EU Sync + Collaboration
Goal: Team usage, institutional sales
- [ ] EU-hosted sync (Hetzner)
- [ ] Zero-knowledge E2E encryption
- [ ] CRDT conflict resolution
- [ ] Shared workspaces
- [ ] Real-time cursors
- [ ] Comments on nodes
- [ ] Version history
- [ ] Offline-first with sync queue
Phase 4: Enterprise
Goal: EUR 5K+ contracts
- [ ] SSO (SAML, OIDC)
- [ ] Audit logs
- [ ] Admin dashboard
- [ ] Role-based permissions
- [ ] Self-hosted (Docker)
- [ ] REST API
- [ ] Webhooks
- [ ] SLA options
AI Compatibility
"Agent-Ready" Data
Users want to point Ollama at their notes. We make this easy: - SQLite database (queryable) - Markdown content (readable) - JSON export (portable) - Built-in agent with tool calling
Local LLM Agent (Ollama)
The canvas includes a built-in agent that can build and modify knowledge graphs via natural language.
Chat transcript (required behavior): The agent is a chat window occupying the full height of the canvas's left edge, not a band across the header: a transcript needs vertical room, and a header strip can give it none. The left edge is deliberate - the right one belongs to the storyline overview, reader, and timelines. Within the panel the conversation takes the free space at the top and scrolls, while the context line and the input row are pinned at the bottom, in that order, since the input belongs at the foot of the conversation it feeds. Every canvas overlay anchored to the left edge - node preview, edge filters, hover tooltip, citation progress, the status bar and the agent log - is offset by the panel's width through --canvas-chat-inset, mirroring how --canvas-right-inset clears the storyline layers. The gate deriving this scans the canvas stylesheets rather than naming the overlays: a list has to be added to, and the overlay nobody adds is the one that ends up under the panel, as the status bar did - taking the agent log button with it. Anything genuinely exempt is named in the gate with its reason; right-anchored overlays such as the minimap and zoom controls are unaffected and must not carry that inset.
The panel folds away like the storyline panel, on the shared step easing, and the folded state persists across sessions. It starts folded: the canvas is what the application opens for, and a panel covering its left edge before anyone asked for it takes that space by default. A stored choice always wins, so a user who leaves it open finds it open.
Only a deliberate control records that choice. The corner toggle and Settings persist the fold state; an edge push reveals or dismisses the panel for the session and writes nothing. An edge push is a gesture that fires from ordinary pointer travel, and recording it as a preference meant one accidental brush against the left edge changed every future startup. Values written by that path are cleared once on upgrade, since they record an accident rather than an intent. Its single toggle sits in the canvas's top-left corner, outside the panel: a control that travels with the panel disappears exactly when it is needed. The toggle reads as active while the panel is open and as inactive while it is folded, matching every other toggle in the application; the reverse tells the user the panel is open when it is not. A left-edge push also reveals it, mirroring the right-edge push that reveals the storyline overview - but only once no storyline layer is left to step back through, so the left edge dismisses the reader and overview first. While folded the panel contributes no inset and the left-anchored overlays reclaim the space - on the same easing and duration as the panel itself, timed by --chat-inset-duration (0s while the panel's separator is being dragged, mirroring --inset-duration on the right). A panel that slides while everything beside it teleports reads as a glitch, not an animation. A second left-edge push while the panel is open folds it away again: the push that revealed it is also the push that dismisses it, as with the storyline layers on the right. The panel's bottom follows --canvas-bottom-inset, so it ends above an open timelines sheet rather than disappearing behind it. The transcript shows the exchange in order: each prompt the user sent, and each answer the agent gave in full. Whenever the bar is visible the transcript area is visible too: before the first exchange it holds a placeholder naming itself as where answers appear, because an area that materializes only once output exists leaves the same question - where does the output go - unanswered. An answer that arrives as plain text is displayed as written - never truncated - because otherwise a reply that performs no canvas action leaves no visible trace at all. The mirror case binds equally: every way a run can end leaves a line in the transcript. A run that finishes in tool calls, pauses for plan approval, stops on the user's command, hits its iteration limit, or fails, says so where the conversation is. Otherwise the panel shows a tool-call count and then silence, and the user cannot tell a finished task from a hung one - which is exactly what a question answered entirely by canvas actions looked like.
Under each assistant turn, the actions taken during it collapse into a single line (4 tool calls) that expands to the list of tools and their outcomes. This is a summary of what the agent did, distinct from the activity log panel, which remains a diagnostic surface for errors and opens itself only when a run genuinely fails.
The transcript persists for the session, scrolls to the newest turn as it arrives, and is cleared by the same control that clears the agent's conversation memory, so what the user sees and what the model remembers are cleared together.
Context indicator (required behavior): The bar states what the agent will actually see before the prompt is sent. With nodes selected, the context is that selection and the bar names them (the first few titles, the remainder as +N more, the full list on hover); with nothing selected the context is the whole filtered graph and only its node count is given, since naming hundreds of nodes informs nobody. Untitled nodes are named as such rather than rendered blank. The displayed list and the list handed to the agent are derived from one computed source, so the claim cannot drift from the payload.
Architecture:
- Direct Ollama API integration (/api/chat with tools)
- Tool calling with native support + fallback JSON parsing
- Stateless requests (no history bleeding between requests)
- System prompt includes current canvas state (existing nodes)
- Queue manager: Sequential request processing to prevent race conditions
Graph Agent Tools: every tool the in-app agent can call. Which ones are offered depends on the mode: explore is read-only, plan designs for approval, execute may mutate. A gate test fails when this list and the registry disagree.
Nodes and edges
| Tool | Description |
|---|---|
create_node(title, content, x, y, date, date_end, tags) |
Create a new node on the canvas with a title and markdown content |
create_edge(from_title, to_title, label, color) |
Create an edge connecting two nodes by their titles |
create_edges_batch(edges) |
Create multiple edges at once. More efficient than create_edge for mind maps and graphs |
create_nodes_batch(nodes) |
Create or update multiple nodes. Handles any size array by processing in chunks |
update_node(title, new_content, date, date_end, tags) |
Update ONE node: content, date or tags. For multiple nodes use batch_update |
update_edge(from_title, to_title, label, color) |
Update an edge label or color by specifying the connected node titles |
add_to_zotero(node_ids) |
Add nodes to the user's Zotero library as new items; a DOI already in the library is not added again |
update_title(title) |
Change the note title |
update_content(content) |
Update the note content with new text. THIS SAVES YOUR WORK |
append_content(text) |
Append text to the end of the note |
move_node(title, x, y) |
Move a single node to a new position |
batch_update(updates) |
Update multiple nodes. LLM decides values. Use for titles, content, OR positions |
delete_node(title) |
Delete a single node by its title |
delete_edges(filter) |
Delete edges. Use to remove connections without deleting nodes |
delete_matching(filter) |
Delete multiple nodes matching a filter |
generate_sequence(count, title_pattern, content_pattern, layout, connect) |
Generate N nodes with a pattern. Use for large batches (100+). Pattern uses {n} for number |
format_math() |
Reformat the math in the note to Typst syntax using the model. Use this when the note contains LaTeX (like \frac{a}{b} or \alpha) or other non-Typst math that should render correctly |
node_done(summary) |
Signal that the node editing task is complete. You MUST call update_content first |
Tag groups and storylines
| Tool | Description |
|---|---|
tag_nodes(tag, node_titles) |
Add one tag to each named node, keeping its other tags; the name is converted to a valid tag |
create_storyline(title, description?, node_titles?) |
Create a storyline and thread the named nodes into it, in order |
add_node_to_storyline(storyline_title, node_titles) |
Append existing nodes to an existing storyline |
list_storylines() |
List the storylines in this workspace |
Reading the graph
| Tool | Description |
|---|---|
read_graph(mode, include_content, max_content_length) |
Read the current graph state. Auto-adapts to available context. Modes: "auto" (default), "titles", "summary", "full" |
get_connected_components() |
How many separate groups the graph falls into, and which nodes are in each. Without it, a claim that the graph is connected can only be a guess |
query_nodes(filter) |
Query nodes from database. Returns list of {title, content} for planning |
for_each_node(filter, action, template) |
Process nodes: set/append content with templates, or use LLM to generate/transform content |
check_completeness(topic, findings) |
Assess if research on a topic is complete. Returns coverage score and suggests follow-up queries if gaps exist |
Selection
| Tool | Description |
|---|---|
update_selected_content(content) |
Replace the content of the selected node(s). Use when user says "update this", "change this to", etc |
append_to_selected(text) |
Append text to the end of the selected node(s). Use when user says "add to this", "append", etc |
rename_selected(title) |
Rename the selected node. Only works with single selection |
color_selected(color) |
Set the color of all selected nodes. Use when user says "color these", "make these red", etc |
delete_selected() |
Delete all selected nodes. Use when user says "delete these", "remove selected", etc |
connect_selected_to(target_title, label) |
Connect the selected node(s) to another node by title. Creates edges from all selected to target |
summarize_selected(instruction) |
Create a summary of all selected nodes. Generates a new node with the summary |
expand_selected(instruction) |
Expand the selected node with more detail. Use when user says "expand this", "add more detail", etc |
Layout and colour
| Tool | Description |
|---|---|
auto_layout(layout, sort) |
Arrange nodes in a layout |
smart_move(instruction) |
Move nodes based on semantic criteria. LLM reasons about each node. Use for "move cars left, animals right" |
smart_connect(groups) |
Connect nodes within semantic groups. E.g., "connect animals together, connect cars together, but not across" |
smart_color(instruction) |
Color nodes into multiple categories based on what they represent. LLM semantically classifies each node |
color_matching(pattern, color) |
Color nodes by SEMANTIC criterion (what nodes represent). Use for categories like "person", "organization", "question". NOT for text patterns - use color_regex instead |
color_regex(regex, color, field) |
Color nodes by regex pattern on title. Use for "starts with x" (^x), "ends with .md" (.md$), "contains foo" (foo). Fast batch operation, no LLM needed |
reset_edge_colors() |
Reset all edge colors to default. Removes custom colors from all edges |
Themes
| Tool | Description |
|---|---|
create_theme(name, description) |
Create a new custom theme. LLM generates YAML based on description |
update_theme(name, changes) |
Update an existing custom theme based on changes description |
apply_theme(name) |
Switch to a named theme |
list_themes() |
List available themes |
Research
| Tool | Description |
|---|---|
research(query, sources) |
Research a topic across web and local nodes. Returns results with source attribution |
deep_research(topic, depth, aspects) |
Perform deep, iterative research with cross-validation. Use for comprehensive research that needs multiple rounds of queries, Wikipedia article fetching, and source validation. Returns findings with confidence levels |
research_topic(topic, target_count, batch_size) |
Research a topic and create many nodes. Makes multiple LLM calls to avoid truncation |
web_search(query) |
Search the web for information. Use this to research topics before creating nodes |
fetch_url(url) |
Fetch and read the content of a web page. Use this after web_search to read full articles |
fetch_wikipedia(title) |
Fetch full Wikipedia article content for a topic. Use to get detailed information on a specific subject |
wikipedia_search(query, limit) |
Search Wikipedia for articles matching a query. Returns list of matching articles with snippets. Use this to discover relevant Wikipedia articles before fetching full content |
validate_claim(claim) |
Cross-validate a specific claim or fact across multiple sources. Returns confidence level and supporting sources |
build_knowledge_base(topic, scope, target_nodes, phases) |
Build a comprehensive knowledge graph about a topic. Runs multiple research phases with supervisor checks. Use for "create a knowledge base about X" requests |
check_progress(topic) |
Ask the supervisor to evaluate current knowledge graph progress. Returns recommendations |
expand_aspect(aspect, depth) |
Expand the knowledge graph by researching a specific aspect. Use after check_progress identifies gaps |
Reasoning, planning and memory
| Tool | Description |
|---|---|
think(thought) |
Express your reasoning or thinking process. Use this to plan before acting |
plan(tasks) |
Create a task list for a complex operation. Each task will be shown in the log |
create_plan(title, steps) |
Create a detailed plan with steps for user approval. Every step MUST declare its "action" so the user can see what will be created vs edited before approving. IMPORTANT: Plans for graphs MUST include separate steps for: 1) Creating nodes, 2) Creating edges with labels, 3) Applying layout |
request_approval(plan_id, message) |
Request user approval for the current plan. Agent will pause until user approves, rejects, or modifies |
update_task(task_index, status) |
Update the status of a task in the current plan |
set_goal(goal, steps) |
Start tracking a new goal. Clears previous session memory |
update_progress(progress, completed_action) |
Update progress on current goal (0-100%) |
complete_goal(summary) |
Mark current goal as complete and clear session memory |
push_task(description, priority, context) |
Add a task to the todo stack for later. Tasks are processed LIFO (last in, first out) |
pop_task() |
Get and remove the top task from the stack |
peek_stack() |
View the task stack without removing tasks |
clear_stack() |
Clear all tasks from the stack |
remember(message) |
Store important information for future reference in this conversation |
done(summary, force) |
Signal completion. BLOCKED if graph has nodes but no edges - you MUST create edges first with create_edges_batch |
Node Agent Tools (per-node AI):
| Tool | Description |
|---|---|
web_search(query) |
Search the web (optional) |
fetch_url(url) |
Read full web page content |
wikipedia_search(query) |
Search Wikipedia |
update_content(content) |
Replace note content (auto-converts LaTeX to Typst) |
append_content(text) |
Add text to note |
update_title(title) |
Change note title |
done(summary) |
Signal completion (requires prior update_content) |
Key Design Decisions:
-
Upsert Behavior:
create_nodes_batchchecks existing titles (case-insensitive). Updates existing nodes, creates new ones. Prevents duplicates. -
Iterator Pattern:
for_each_nodewith filter allows batch operations: for_each_node({ filter: "empty", action: "search", template: "{title} info" })-
Processes only nodes matching filter
-
Query Before Execute:
query_nodesreturns node list for planning before action. -
Thinking Layer: Web search refines query via LLM before executing search.
-
No Clear Canvas: Removed from agent tools to prevent accidental deletion.
-
Stateless Requests: Each request starts fresh with current canvas state in system prompt. No conversation history pollution.
-
LaTeX to Typst Conversion: Node agent auto-converts LaTeX math (
\[...\],$$...$$) to Typst format when saving content. -
Per-Workspace Memory:
remembertool saves information to localStorage, persists across sessions. -
Content Enforcement: Node agent must call
update_content()beforedone()or request is rejected.
Internationalization (i18n)
Nodus supports multiple EU languages:
| Language | Code | Status |
|---|---|---|
| English | en |
Complete |
| German | de |
Complete |
| French | fr |
Complete |
| Spanish | es |
Complete |
| Italian | it |
Complete |
Language Selection: - First launch: Language selector appears in onboarding flow - Settings > General: Language dropdown to change anytime - Persistence: Choice saved to localStorage, persists across sessions - Browser detection: Auto-detects browser language on first launch
Implementation:
- Uses vue-i18n with lazy loading for non-default locales
- Locale files: src/i18n/locales/{lang}.json
- All UI strings are translatable; user content remains in original language
First-run tour
Required behavior: The tour covers what distinguishes the product, not only what a user would guess from looking at a canvas.
- Creating a node, connecting two, dropping a file and typing maths are all discoverable by trying. The features that make this more than a whiteboard - focusing on one node's neighbourhood, threading nodes into a storyline that leaves the graph as a document, and asking the agent to work on the graph - are not discoverable at all, and a tour that omits them ends the first session with a canvas of notes and no reason to come back.
- Every step points at something the seeded default workspace already contains, so a step can be followed the moment it is read rather than describing a feature the user has no material for.
- A step exists in all five locales or it does not exist. A tour that falls back to English mid-sequence is worse than a shorter tour.
Starter Content
After onboarding (and via Settings > Reset default workspace), the empty default workspace is seeded with starter content. The starter content must demo every user-facing feature, localized in all five locales:
| Feature | Demonstrated by |
|---|---|
| Notes, colors, untitled node | Tutorial and research-example notes (4 colored, 1 untitled) |
| All five edge link types, labels, directed/undirected | Edges between the tutorial and research nodes |
| Wikilinks | [[links]] in tutorial content become auto-edges |
| Typst math, Mermaid diagrams | Dedicated reference nodes |
| Tag groups | demo-project (dated story nodes) and entity-types |
| Storylines (panel, reader, timelines lane) | A storyline threading the three dated project notes in order |
| Timelines / dated nodes | date:/date_end: frontmatter on the project notes (incl. one date range); a dated citation outside the storyline shows the unassigned lane |
| Hashtags / tags | #hashtags in the project notes; the shared ones become tag nodes |
| Entity node types | One node each: citation (with DOI), comment, character, location, term, item |
Resetting the default workspace also removes its previous storylines before reseeding, so repeated resets do not accumulate duplicates.
Contents sidebar
Required behavior: The contents sidebar is a table of contents, not just a node list. Sections imported from a paper carry their subsections inside their markdown, and a contents list that hides them makes a chapter opaque.
- Under each node entry, the markdown headings inside that node's content are listed, indented by heading level.
- Clicking a subheading scrolls the reader to that heading within its section, exactly as clicking a node entry scrolls to the section.
- Headings inside fenced code blocks are not headings and never appear.
Editing in the reader
Required behavior: Reading is where the gaps show, so the reader is where the fix should happen. Leaving the reader to edit a paragraph and coming back loses the place and the flow.
- Double-click a section's text or its title to edit that node's markdown in place. Save with Cmd/Ctrl+Enter or by clicking away; cancel with Escape.
- A double-click on a link or a control inside the text keeps its own meaning and does not open the editor. For a time only the title took the gesture, to keep double-click word selection in the body; the body went on saying "Double-click to edit" and did nothing, which is the worse of the two. Text in the body is selected by dragging.
- Saving writes through the same store path as canvas editing, so file sync, undo and the anchored-wikilink rendering behave identically. The section re-renders on save.
- Editing acquires the node's file lock first, exactly as the canvas does. If the file is locked by another program, the reader says so and the text stays read-only; it must never silently fork a locked file.
- One section edits at a time. Starting an edit in another section saves the current one first.
Reader opening and switching
Required behavior: The reader's slide animation and its content rendering compete for the same main thread. Rendering every node's markdown in one synchronous pass during the slide starves the animation frames, so the panel judders exactly when the user is watching it most closely.
- The reader stays mounted after its first open and slides with a transform, exactly as the storyline overview, the chat panel and the timelines sheet do. A panel that mounts fresh on every open loads and paints during its own entrance; a mounted panel slides as one already-painted surface, and its scroll position and sidebar state survive closing.
- While the reader is open, the minimap and zoom controls fade out instead of following the right inset to mid-screen. The inset keeps overlays beside a panel the user works alongside; the reader is a panel the user works in, and graph navigation chrome floating at the centre of the window during reading is noise, not navigation.
- The first screenful of content renders before the slide begins, in one pass small enough not to delay it. The remaining sections wait until the slide has settled, then fill in batch by batch with an animation frame between batches - below the fold, where filling in is invisible. Content that pops in while the panel is moving reads as flicker, which is the artifact this ordering exists to prevent.
- Switching to another storyline while the reader is open keeps the current content visible until the new storyline's nodes have loaded. The loading state appears only when the reader has nothing to show; replacing readable content with a spinner is a flash, not feedback.
Rendering node content
Required behavior: A node's markdown is rendered when it is on screen, not because it exists. Rendering every node in the workspace in one synchronous pass costs about half a millisecond per node - 157ms measured for 300 nodes - and that time is spent on nodes nobody is looking at, in a burst that blocks the frame.
- The rendering pass covers the nodes the viewport shows, plus any node being edited, and it caches by content so an unchanged node is never rendered twice.
- A node that scrolls into view renders then. Culling already tracks what is visible, so the set is available without new bookkeeping.
- A card renders a preview, not a document. A node holding an imported paper can carry tens of kilobytes of markdown; rendering 73KB measured at 82ms, five dropped frames for one click, into a card a few hundred pixels tall. Cards render the leading portion of the content, and the card says the text continues. The full document renders where it is read - the fullscreen view and the storyline reader, which render independently - so nothing is lost, only deferred to the surface that shows it.
- The preview is sized to the card, not to a constant. A 200x120 card shows about 162 characters, 27 across and 6 down, while a flat 4000-character cap rendered an average of 896. Measured over 400 nodes of a real vault that is 38 DOM elements per card against 7, and the difference is built, styled and painted for text that cannot be seen. The cap is derived from the card's width, height and the user's font scale, with headroom because markdown syntax does not render and lines break early, a floor so a small card still says something, and the flat cap as its ceiling. The card's size is part of the render cache key, so a card that is resized fills the space it gained.
Staging what the viewport mounts
Required behavior: While a viewport gesture is live, newly visible cards mount a few per frame rather than all at once.
- A grid layout crosses the viewport margin in columns, so mounting is a sawtooth rather than a steady cost. Measured on a dense workspace at the density that puts ~360 cards in view: churn is 0 for most frames and then 20 in a single one; at ~870 visible it is 30, and 60 to 120 under a fast drag. Mounting is the expensive part - swapping some 300 cards between renderers is recorded as costing seconds - so the burst is what is felt while the median frame stays healthy and hides it.
- The margin is what makes staging safe. It exists so a node mounts before it is on screen, so admitting it a frame or two later costs nothing visible: it is still outside the viewport when it arrives.
- Departures apply immediately and in full. Unmounting frees work, and keeping a node the viewport has left would only add to the next frame.
- Staging is confined to the gesture. With the viewport still there is no burst to spread, and a fresh load should paint at once rather than trickle in.
- A card fades in as it mounts, over 120ms. Normally this is never seen: cards mount in the margin, off screen, and are fully faded before they are visible. It earns its place on a fast drag, when staging falls behind and a card would otherwise appear abruptly mid-view. The animation is opacity only, so it composites rather than repaints, and it is dropped entirely under prefers-reduced-motion.
Measuring canvas performance
Required behavior: The canvas can be asked how long its frames take, in the running app, on the real graph.
- Probing from outside the browser repeatedly cleared the code of blame while the canvas stayed laggy: culling and node styling measured 0.015ms per frame over a steady pan of a 360-node workspace, and the edge model 0.41ms for 1,000 edges, both against a 16.7ms budget. What such a probe cannot see is rendering - the Vue patch, layout, paint and compositing - which is where the time goes.
- Settings > Canvas > Show performance readout puts frame timing over the graph: median, 95th percentile, worst frame, how many missed the budget, and the time attributed to each named phase. It also states which renderer is live, and how many nodes and edges it is drawing, because the two modes fail differently.
- Frames are sampled only while a viewport gesture is live. That is when a stutter is felt, and a permanent frame loop would itself be work the canvas does not need.
- The readout is off by default. It is a diagnostic, not a feature, and it draws over the graph.
Painting while the viewport moves
Required behavior: A live pan or zoom drops the canvas's decorative paint - the drop-shadow glow on edges - and restores it when the gesture ends.
- The per-frame JavaScript is not the cost. Measured over 120 frames of a steady pan on a real 360-node workspace, culling and node styling together take 0.015ms per frame against a 16.7ms budget: a tenth of one percent. Blaming culling, styling or markdown rendering for pan lag is measuring the wrong thing.
- What costs is paint.
filter: drop-shadow()on an edge forces its own paint pass over a region larger than the path, and.edge-highlightedcarries one in every theme while the cyber theme puts one on every visible edge. Selecting a hub highlights its whole fan, so the count scales with the graph, not the viewport. - This is why zooming out is smooth and zooming in is not: above the LOD threshold edges are drawn on a 2D canvas, where no CSS filter applies. The filtered SVG edges only exist at the zoom levels where cards are shown.
- Suppression is keyed to the gesture, not to panning alone, so a pinch zoom gets it too. Nothing is visually lost: the glow is imperceptible while the view is in motion, and it returns the moment the gesture settles.
Minimap redraw
Required behavior: A viewport move redraws the minimap's viewport rectangle and nothing else. The marks that stand for the nodes are computed once per node set and reused.
- The minimap draws one mark per node in the workspace, not per visible node, so its cost scales with the graph while the canvas above it scales with the viewport. Panning eight cards must not redraw a thousand marks.
- Every mark's geometry was recomputed inside the template, and the position function was called once for each of x, y, width and height - four calls per node per frame. Measured over 60 viewport-only frames: 0.74ms per frame at 168 nodes, 4.47ms at 1581, against a 16.7ms budget that also has to cover the canvas itself. That is a floor, measured without the SVG rasterisation a browser adds.
- The marks are therefore precomputed as a list and rendered by their own component. A viewport move leaves that list untouched by identity, so the marks are not re-rendered at all; a node move, a resize or a change of selection rebuilds it once.
- The minimap draws the nodes the canvas is drawing, at the positions the canvas is drawing them. In neighbourhood mode that is the subgraph on screen at its overlay positions: showing the whole workspace there marked a graph that is not on the canvas, and placed the viewport rectangle among coordinates nothing is drawn at.
Circle size in bubble mode
Required behavior: Every node is visible in bubble mode, however far out the view is zoomed.
- A circle's radius grows with the node's edge count on a log scale, in canvas units, so hubs stand out.
- On screen, no circle is drawn smaller than 4 px in radius, and none can be pressed at less than 9 px. Both floors are applied by one function, so what is drawn and what can be pressed are sized by the same rule.
- A node without edges had the smallest canvas radius, 5 units, and no screen floor: at 10 % zoom it was half a pixel across, so disconnected nodes were effectively invisible while still clickable.
Selected nodes in bubble mode
Required behavior: Above the level-of-detail threshold, nodes are circles on a 2D canvas, except selected ones, which render as real cards so their text is readable. A selected node must stay draggable across that swap.
- The card layer sits above the circle canvas while bubble mode is on. The circle canvas covers the viewport and takes pointer events, and a selected node is deliberately absent from its hit test - so with the card underneath, a press on a selected node reached neither: not the canvas, which no longer knows about the node, and not the card, which was covered. The node became undraggable the moment it was selected.
Collapsed node titles
Required behavior: A title shown at semantic zoom must fit the card it is drawn in. Two ways it did not: a single word longer than the card - a product name, a surname - could not wrap because word breaks were disallowed, so it was clipped at the border; and three wrapped lines at the collapsed type size were taller than the default card, so the text spilled past its own outline.
- A word that cannot fit the card's width breaks rather than being clipped. Breaking mid-word is worse than breaking at a space and better than losing the end of the word.
- The line limit follows the card's height rather than being a fixed number. A count chosen for the default card cuts a long title mid-line on a taller one and wastes space on a shorter one, so the card computes how many lines of the collapsed type size it can hold and clamps to that.
- The computation uses the type size as rendered, which includes the user's font scale, and subtracts the card's border as well as its padding. Assuming the base size and ignoring the border overestimates the budget, and the overestimate shows up as a line cut through the middle - the very artifact the budget exists to prevent.
A truncated title ends at a whole line. The line clamp bounds how many lines render but leaves the box free to be a fraction of a line taller than its content, and the card's own overflow then showed the top of a line that was never meant to be visible - half-height letters along the bottom edge. The header is bounded to exactly its line count times the line height.
Side padding is narrow at this type size. With 24px each side, a default card left about 150px for text - less than one long word at 28px bold - so words broke mid-character.
The type size follows the card, not the other way round. A fixed 28px meant a 200px card held about ten characters a line, so two lines showed about twenty of them. Ordinary titles are longer than that - "General-purpose AI model obligations" is thirty-five characters, "Non-consensual intimate imagery generation" is forty-two - so the clamp truncated almost every title on a default card, and the ellipsis was the normal case rather than the exception. Adjusting the padding, the line bound and the box model each corrected a different artifact of that truncation without removing the truncation itself.
The card therefore chooses the largest type size, up to the base size, at which the whole title fits the lines its height allows. Only a title too long for the smallest readable size is truncated, which makes the ellipsis rare and meaningful again. The line budget and the type size are chosen together, because each depends on the other: a smaller size yields more lines and more characters a line.
The budget subtracts the border the card actually draws. The collapsed card's border is written inline from the zoom, so it is not the 2px the stylesheet declares; assuming the declared value overestimated the height available at every zoom where cards are collapsed.
A write the backend refused
Required behavior: The canvas shows what is stored. A create, delete or update that the backend refused leaves the canvas as it was, and a notification names the node or edge.
- A failed create adds nothing. A node that was never stored can still be linked and edited, then disappears on the next load and takes those links with it.
- A failed delete removes nothing, as deleting several nodes already does.
- The browser build has no backend, so there creates stay local and the interface can be developed without the desktop shell. That exception depends on whether a backend exists, never on whether a call failed. A delete has nothing to confirm it there, so the browser build cannot delete.
Deleting nodes with files
Deleting a node moves its file to the vault's .nodus-trash folder, so the delete can be undone and no text is destroyed.
A node is deleted only once its file is in the trash. Deleting the row while the file stays in the vault leaves the file watcher to read it back, and the node returns - the user deletes something and it reappears. A node with no file has nothing to move and is deleted.
A node whose stored record cannot be read is not deleted. Without the record there is no file path to move, and deleting the row would leave the file for the watcher to read back.
Deleting several nodes therefore deletes those whose files moved and reports the rest by title. The interface removes from the view only what the backend deleted; clearing the view on a failure hid nodes that were still stored, and they came back on the next load.
Redoing a delete or a create
Every action that can be undone can be redone. Undoing a delete puts the delete back on the redo stack, and undoing a create puts the create back.
Neither did. Undo removed the snapshot without recording anything to redo, and redo had no branch for either type, so it popped the snapshot and discarded it. A creation snapshot keeps the nodes themselves rather than only their ids, because an undo soft-deletes them and there is nothing left to look up.
Walking a vault
Hidden files and folders are skipped when walking a vault, but the vault folder itself is always visited. A vault whose own name starts with a dot is still a vault, and testing every entry including the root pruned the walk at once, so the vault scanned as empty. The import walk and the file watcher share one rule.
The rule is tested against the path relative to the vault folder, never the absolute path. A vault that lives inside a hidden folder, such as ~/.config/notes, is still walked and still watched.
A path is inside the vault only when it is the vault folder or lies below it. A sibling folder whose name begins with the vault's name, such as notes-archive next to notes, is outside.
Symbolic links inside a vault are not followed. A link can point outside the vault, where file operations would escape the vault check, or back into it, where the walk would not end.
Refreshing a workspace from its files
Required behavior: A refresh brings in what changed on disk and leaves the arrangement on the canvas alone. Where a node sits is the user's decision, and a refresh has no information that should override it.
- A refresh moves no node: every node keeps the position it has.
Refresh re-ran the import grid for every folder that already had a frame, so each refresh put every node inside a folder frame back into a three-column grid.
Importing a vault
Required behavior: A file that cannot be read or stored does not stop the import. The import takes every other file, then names the ones it skipped and why.
- Linking an existing node to its file takes the file's content and its checksum from one read, as the watcher does, so the node holds what the file holds.
- A node is recorded as synced with its wikilinks only once that sync has succeeded. A failed sync is left for the next pass rather than marked done.
Storyline chain edges
A storyline's sequence is carried by edges belonging to that storyline. Adding a node links it to its neighbours in the sequence, and removing one reconnects the neighbours it sat between.
Whether such an edge already exists is decided by source, target, and storyline. Matching source and target alone let any other edge between the two nodes - a wikilink, a supports edge the user drew - stand in for the chain edge, so none was created and the sequence had a gap.
Removing a node from a storyline changes the chain edges only after the backend has removed the node.
The sequence stays numbered from zero without gaps, whatever reorders came before. A removal either completes or changes nothing: closing the gap after a reorder violated the unique order constraint, and because the removal and the renumbering were not one transaction, the node was gone while the call reported failure.
Recording an undo step
An undo step is recorded when something changes, not when a gesture might begin. A press on a node or a resize handle that never moves has changed nothing, and an entry for it means the next undo does nothing visible while the step the user wanted stays buried.
Dragging waits for three pixels of movement before recording. Resizing recorded on pointerdown, so every click on a handle added a step. Resizing now captures the sizes at pointerdown - they are the baseline, and must be read before anything moves - but records the step only once movement passes the same threshold.
A bulk rewrite is one step. Three batch tools rewrote node content with no undo entry at all, while the single-node tool beside them recorded one. An entry per node would be no better: reversing one instruction would mean pressing undo once per node. Colour and size already model this as one entry holding a map of what changed, and content now does the same.
Recording belongs to the store, not to the caller. It used to be opt-in at each call site: every writer had to remember to record before changing content. Writers kept forgetting - the batch tools recorded nothing, the inline date editor recorded nothing, an agent rewriting a node recorded nothing - and each omission surfaced only when a user pressed undo and nothing happened. Fixing them one at a time never ends, because the next writer has the same chance to forget.
Every content write passes through the store, so the store records it. A caller cannot opt in, and therefore cannot forget. Undo and redo pass skipUndo when replaying, because a replay must not record a new step.
One action is one step. Tool execution is wrapped in a group, so a tool that rewrites three hundred nodes produces one entry rather than three hundred, and a tool author does nothing to make their tool undoable. Within a group the first recording for a node wins, because that is the state undo must return to.
The recorder is connected by the same call that provides the undo handlers, so wiring cannot be done by halves: an unconnected recorder would make every change unundoable, silently.
Claims the agent can check
The agent said "the graph is now fully connected" when it was not. It had no way to know: connectivity was computed for MCP clients and no tool exposed it to the in-app agent, so the answer could only be a guess.
get_connected_components reports how many separate groups the graph falls into and which nodes are in each, and both surfaces offer it. A claim about the graph's shape is one the agent can check before making it.
Showing agent progress
The task panel shows the steps of an approved plan and how far the agent has got. It can be dismissed, and it sits clear of the canvas toolbar.
Progress never advanced, for two reasons at once. There were two task lists - a ref the tools wrote and the store the panel read - so a plan filled one and the tools the other. And the tools that report progress appeared in no mode's whitelist, so the model could never call them. Both are fixed: the tools write the list the panel reads, and they are offered in execute mode.
Rendering the conversation
The model writes markdown, and the transcript renders it. Interpolating the text showed **emphasis** and list markers literally.
The reply passes through the same renderer the canvas uses, so it crosses the sanitisation boundary: a model's output is untrusted text like any other.
Answering a question
A request that asks what the notes say, and asks for no change, is answered: the agent reads what the answer needs and replies.
Every mode's instructions described building. The default mode told the model to read the graph, research on the web, synthesize, draft a plan and request approval, whatever the request was, so a plain question about the workspace set off that whole sequence: nine tool calls and still running, for a question the notes answer.
- The instructions state the rule ahead of the mode's method and say it takes precedence over it.
- For a question the agent makes at most the reads the answer needs - a search of the notes or one reading of the graph - and then gives the answer as its final message. It makes no plan, requests no approval, changes nothing, and uses web research only when the question asks for information the notes do not hold.
- Whether a request is a question is the model's judgement, stated in the instructions; the request is not classified by pattern matching (Classifying what the user wrote).
- The rule is an instruction to the model, so it lowers the number of calls without bounding it. The gate test checks that every mode's instructions carry the rule, not what a model does with it.
What the agent acts on
A run acts on the nodes that were selected when the user asked. The selection is captured when the run starts and released when it ends.
Reading the live selection instead meant a click made while the model was still thinking redirected the change: a user who asked for one node to be rewritten, then selected another to look at it, had the second node overwritten with the first one's new text. Nothing said so, and before the store took over undo recording, nothing could reverse it.
The capture reaches every surface a tool reads its targets from. It was handed to one adapter that the selection tools do not read: those tools take their targets from the tool context the canvas builds for each call, and that context read the live selection, so the rule held only for the tools that never needed it.
A run paused for approval keeps its capture, because the resumed execution is the same run and must act on the same nodes.
The prompt field after sending
Sending a prompt empties the prompt field at once. The prompt is shown in the transcript from that moment, so the field has nothing left to say.
The field was emptied only when the run ended. For the length of the run it showed, disabled, the text already standing in the transcript above it, which reads as a prompt that was not sent.
- The field is emptied when the run starts, not when it ends.
- A run that fails with an error puts the text back, so it can be sent again without retyping. Text is only put back into an empty field.
Dropping a node on the storyline panel
Releasing a drag over the storyline panel adds the node to a storyline instead of moving it on the canvas. The drag handler has to know whether the pointer is over the panel, and the panel is what knows.
That was passed through a property on window. Neither side declared a dependency on the other, so no boundary test could express the contract, and the ordering held only because the panel cleared the flag in a deferred callback that happened to run after the drag ended - reordering either handler would have dropped the node on the canvas instead, silently. It is now a named module both sides import.
Declaring a tool's parameters
A tool's schema says enough for a model to call it without guessing. An array declares what its elements are; eight did not, so nothing told the model whether to send strings, objects, or something else, and a wrong guess arrives as an argument the handler cannot use.
A parameter declared as an object is read as one. push_task declared context as an object and read it as a string, so every object a model sent was dropped without a word.
An array's declared element type is the type the handler reads. create_nodes_batch declared an array of strings and read title and content off each element, so a model that followed the schema produced a batch of untitled, empty nodes.
Every property in a schema is a parameter the handler reads. batch_update carried an items property beside updates, which is the element type of updates written one level too high: it declares a parameter that does not exist and leaves the real array's elements undeclared.
Deciding an agent run has ended
A run ends when the model calls done, or when it asks the user a question the loop cannot answer. It is never decided by reading the wording of a reply.
The wording was read: a reply matching "created", "done", "finished" or "successfully" ended the run. So a message describing what the model was about to create ended it with the work undone, and a completion phrased any other way was missed and the loop carried on. This is the pattern the project rules forbid - regex over natural language is fragile and fails on varied phrasing.
A reply with no tool call gets one request to act or to declare itself finished. A model that answers with prose twice has stopped working, whatever the prose says, and the run ends saying so rather than claiming the model reported completion.
Superseding an agent run
Starting a run while one is in progress supersedes it. The superseded loop is still awaiting its request, so it must not write the new run's state when that request finally rejects.
Each run takes a generation number, and every write to shared state - the running flag, the log, the node's content - happens only while that generation is still current. Without it, the old loop cleared the new run's running flag, pushed into the log the new run had just reset, and could overwrite the new content from a tool call already in flight. Stopping advances the generation for the same reason.
A superseded loop stops iterating rather than merely stopping its writes. Guarding each write leaves the loop running: it keeps requesting completions and keeps executing the tool calls that come back, and a tool that writes through the store rather than through the guarded state still lands on the note. Both run loops check the generation at the top of each iteration and before any write to the node, so a superseded run makes no further request and no further change.
One path for a tool call
A tool call recovered from the text of a reply is executed on the same path as one the provider returned in tool_calls. Models that cannot emit native calls write them as fenced JSON, and that text was executed directly: it skipped the mode allow-list, so a plan-mode model could run a mutating tool the request had stripped, and it skipped the log and the transcript, so the call happened with no line saying so.
- One function handles a call whatever its shape: allow-list, execution, log line, transcript, marker handling.
- A message recognised as a tool call is not also appended to the transcript as prose.
A reply that carries a tool call it could not make
Models that cannot emit native tool calls write them into the text, and they do not agree on how. A reply carrying such markers is never shown to the user as prose: it is not an answer, and rendering it puts raw markup in the transcript where the agent's words belong.
- A format the application can decode is executed on the one path every call takes, with the mode's allow-list, the log line and the transcript record that path applies.
- A format it cannot decode is reported back to the model, naming what it should send instead, so the run continues rather than stopping on a reply nobody can use. The model is told once; a second unusable reply ends the run, as an unusable prose reply already does.
- The user is told the reply could not be carried out, rather than being shown its markup.
One model wrote call:name(key:value) with its own string delimiters, and every pattern missed it, so seven node creations and eight edges arrived in the chat as text. The same reply also repeated a key, which is the model's own error: a decoder reports such a call as unusable rather than guessing at what was meant.
One implementation per tool
A tool call is answered by exactly one layer. The canvas tries the registry, then the marker handlers, then the LLM-dependent tools, and takes the first real answer, so a tool with a real handler in two layers runs the first and the second is dead code that reads as the live one. Four tools had two implementations, and the unreachable copy was the one later edits were made against.
- A registry handler returns
__UNHANDLED__only when the real implementation is elsewhere. - A gate asserts that no tool name has both a real registry result and a case in the LLM-dependent dispatcher.
A whitelist names registered tools
A mode's whitelist filters registered tools, so a name in it that matches no tool does nothing. Such names read as capabilities the mode has: the execute whitelist named tools for reporting progress that were never registered under those names, with a comment citing the rule that requires progress to advance.
- The reachability gate checks both directions: every registered tool reaches a mode, and every name a mode lists is a registered tool.
Reads that stay live
The agent's view of the workspace is read when a tool runs, not when the agent was composed. The canvas builds the agent's store adapter once at setup, so a value copied rather than exposed as a getter freezes at that moment: the workspace identifier was copied, and after the user switched workspace the memory tools kept writing to the old one.
- Values the adapter exposes are getters.
- A
computedderives from reactive state. One built from a plain function call has no dependency that can invalidate it and never recomputes, which is how the model and context length reported for a run stayed at the values held when the panel was created.
Classifying what the user wrote
What the user meant is decided by the model, never by matching patterns against their words. The phrasings are unbounded: a prompt was classified into a graph type and a domain by regular expressions, and a colour criterion was judged literal or semantic by testing whether it contained a quotation mark, the word "of", or a leading capital - so "Papers of Hinton" took the literal path and "papers about learning" the semantic one, for reasons no user could see.
- A classification that changes what the agent does is either asked of the model or removed.
- Where it adds little, removing it is the better fix: the graph-type enhancement appended two lines the system prompt already carried.
The prompt of an approved run carries its plan
A run resuming after approval is given the plan the user approved. The prompt has a section for it, and the value that section renders from was never assigned, so it could never appear and the model executed approved plans it could not see.
Retrying a provider failure
A failure that says the provider is busy, unreachable for a moment, or took too long to answer is tried again before it reaches the user. One that says the request itself was wrong is not: retrying a bad key or an unknown model only repeats the same answer more slowly.
A gateway timeout was missing from the list. Something between the application and the model gives up on a slow generation and answers 504, and that is the most ordinary way a long run fails: an agent researching a topic gathers findings for minutes, then asks for an answer carrying all of them, and the proxy in front of the model runs out of patience before the model finishes. A single retry usually carries it. Instead the run ended on the first refusal, with a message reading as an empty object, because a gateway that times out sends no body to quote.
- A gateway status - it timed out, or it could not reach the model - is retried like the other transient ones.
- A status with no body says what the status means, rather than showing an empty body as if the provider had said nothing worth repeating.
Reporting a provider failure
A failed request says what the provider said. The message the API returned was parsed and then thrown inside the try whose own catch replaced it with generic text, so a bad key, a rate limit and an unknown model all arrived as the same sentence.
- The parsed message survives to the caller.
- A failure that is not a connection failure is not reported as one. A malformed chunk from a running server was reported as "Cannot connect to Ollama. Start it with: ollama serve", which sends the user to start a server that is already running.
- Availability is decided by the one shared probe, for every provider. Three providers delegate to it and one re-implemented it by hand, so a change to the shared rule would have skipped that provider silently.
Reading an event stream
A streamed response is split on event boundaries as the specification defines them, which are blank lines with either line ending. Splitting only on \n\n meant a server sending \r\n\r\n produced no events at all and the stream was reported as having ended before it was complete. Whatever remains in the buffer when the stream closes is dispatched rather than dropped.
Showing a task's stored context
A task's context is stored as an object and rendered as text. Interpolating it directly printed [object Object], so the context the model pushed came back to it as nothing.
Reporting what a multi-phase run found
A run that works in phases reports what all of its phases found. The knowledge-base build overwrote its accumulator each phase, so the completion payload carried only the last phase's results, typically the smallest, and what the earlier phases found was dropped without a word.
Where research calls live
One module makes the research calls. Wikipedia search was written three times over, twice in the agent composables and once beside the article fetch it belongs with, with different timeouts and different result shapes, so a fix to one left the others as they were.
One connection attempt at a time
The server reconnects, and several tool calls can want the link at the same moment. One attempt runs at a time, and a socket it replaces is detached.
Each call made its own attempt: with Nodus down, ten tool calls produced ten reconnection chains, each replacing the socket the others were holding. Handlers on the superseded sockets went on firing. An old socket's opening sent authentication down whichever socket was current, an old socket's reply could mark the connection approved, and an old socket's closing scheduled yet another attempt.
- An attempt already in flight is joined rather than started again.
- A socket that is replaced is closed and its handlers removed, and an event from a socket that is no longer the current one is ignored.
- A disconnection the application asked for does not reconnect. Closing the socket ran the same handler as a dropped link, so a deliberate disconnection reconnected at once.
Reconnecting to Nodus
The MCP server outlives any one Nodus session, so it reconnects. A tool call attempts a connection when there is none - which the startup message already promised and nothing implemented - and a successful connection restores the retry budget, because a counter that never reset ended reconnection for good after ten drops across a long session.
Queueing citation fetches
Each queued paper carries the direction it was queued for. The direction used to be read once when the run began, from a value a later request overwrote: papers queued mid-run were fetched in whichever direction the run started with, and papers already queued could have their direction changed under them.
Reporting MCP errors
A failure says what went wrong, and its code says what kind of failure it was.
- The code a handler chose is the code returned. It used to be discarded and guessed from the message text, so any message containing "not found" became an invalid-parameters error and every other deliberate code became an internal error.
- A request the user rejected is told apart from one still waiting. Both carry the same code, and treating them alike reported a refusal as "waiting for approval" while the caller's promise never settled.
- Requests still in flight when the socket closes are rejected. A promise nobody will settle looks to the caller like a request still running.
- "Not connected" covers three situations - never reached, dropped, and waiting for approval - and each says which, because the advice differs.
- A rejection is recognised by the code and the state it carries, never by the words in its message. The text was matched against "reject", "denied" and "declined", so a refusal phrased any other way read as a wait. The refusal names no request to settle, so the connection itself records it: every later call then says the user refused, rather than that approval is still pending.
A result keyed by what identifies a node
A result that maps to nodes is keyed by the node's id. Titles are not unique, and keying by title silently merges the nodes that share one: the adjacency list came back with fewer entries than the graph has nodes, and which of them survived depended on the order they were walked. The tool's own description already promised ids.
A routed tool is an advertised tool
A name the request router accepts is one the server advertises, and the reverse. A name routed but never advertised answers a request no client can send, which is a handler kept alive by nothing; three were in that state.
- Every case the router handles names an advertised tool, and every advertised tool is routed.
- A gate compares the two lists, so they cannot drift apart.
An import the built MCP server can resolve
The MCP server is compiled by tsc and run by Node as an ES module. Node resolves a relative import specifier literally, so ./readTools is not found where ./readTools.js is, and tsc copies the specifier as written without reporting it. The tool index imported its six tool groups without the extension; the build succeeded and the server stopped at startup, so no client could connect.
- A relative import or re-export that survives compilation ends in
.js. Type-only statements are erased and are exempt. - A gate reads the server's source for such imports, so a build that cannot start fails the tests instead.
The colour a node is given
A colour set through a tool is the colour the interface offers. The canvas stores a node's colour as a translucent tint and layers it over the surface, so a card keeps its depth and reads the same in light and dark themes, which the palettes are paired to convert between. Anything that needs the colour solid converts it where it is used, as the timeline marks do.
The MCP server kept a second palette of its own: eight saturated hex values. They appear in none of the canvas palettes, so a colour set by an agent matched no conversion and was painted raw, turning a card into a solid slab that ignored the theme.
- A colour name resolves to the palette value the colour bar writes, whoever asks for it.
- Those eight saturated values are recognised as the colours they were meant to be, so the nodes already carrying them correct themselves rather than staying solid.
- The ontology importer kept a ninth: it wrote a saturated purple onto every class node it created, which is how most solid nodes in a vault built from an ontology got that way. It is recognised as the palette's purple, so those nodes read like any other purple one. The same value on a
subClassOfedge is left as it is, because an edge stroke is drawn as given. - Grey is recognised even though it is not offered. A node that already carries it stops rendering as a slab, but the bar still does not hand grey out for a node.
- Edges and storylines keep solid values: an edge stroke is drawn as given, a timeline lane is painted opaque on purpose.
- The node palette holds seven colours and no grey, so grey is not offered for a node. It stays available where it already works.
- The row of colours in use offers only what the presets do not. Its purpose is to reach a colour the palette does not carry, so listing a colour that is already a swatch directly below it repeats the same choice twice and crowds out the custom ones it exists for. A colour the current theme's palette does not offer still belongs there, because in that theme it is not otherwise reachable. When every colour in use is a preset the row is empty and disappears, separator included.
Placing the colour bar
The colour bar, shown while nodes are selected, is centred in the part of the canvas that is left free.
Other layers cover the canvas: the agent panel and the node preview panel from the left, the storyline reader from the right, the minimap in the top right corner. The bar was centred on the whole canvas, so with the reader open its right half ran under the reader, and it lay over the minimap.
- The free part runs from the right edge of the agent panel and of the preview panel, when shown, to the left edge of the minimap, when shown, and of the reader.
- The bar is centred in that part and no wider than it; when its colours do not fit on one line they wrap onto the next.
- The bar follows the reader as it slides, in step with the minimap.
A node's colour is a colour or nothing
A node's stored colour is a value a stylesheet accepts as a colour, or it is empty. Nothing else is stored, and nothing else is painted.
The MCP colour tools declared their colour parameter as a string and described resetting as "or null", so a caller sent the word null. It was stored as the colour. The card background is the colour layered over the surface, and a background written with a word that is not a colour is discarded when it is computed, which leaves the card with no background at all: the card was see-through and the edges behind it showed.
- Resetting a colour over MCP takes JSON
null, an empty string or the wordnull; all three store an empty colour. A value that is neither a colour name the tools offer nor a colour value is refused with an error rather than stored. - The store refuses to write a colour that is not a colour value, whoever asks, and writes an empty colour instead.
- A node already holding such a value is reset to an empty colour when the application loads. Measured on the vault this was written against: 4 nodes, all holding
null. - The card background is built only from a colour value, so a card is never left without a background.
Finding nodes by colour
A colour name and its hex value are the same colour. Writes normalise a name before storing it, so reads normalise too - comparing the raw input meant every query by name, which is how the tool documents itself, matched nothing.
Reporting what a batch did
What a tool reports is what it did. The model reads these strings as the record of the action it just took, so an inaccurate one teaches it something false about the graph.
Two were inaccurate. A batch update counted lines of output rather than nodes, so a node both renamed and moved counted twice, a node whose content changed counted not at all, and titles that matched nothing were counted as updated - it now counts nodes and names what it could not find. And batch_move_nodes offered relative offsets that its schema does not accept and its handler does not implement, so a model taking the description at its word would send deltas and move nodes to the wrong place.
Radial rings
A radial layout places each depth on a ring. When a depth holds more nodes than its ring can seat, it splits across several rings, and each ring seats as many as its own circumference allows.
Capacity was computed from the outermost radius the layout permits, while the nodes were placed at the ring's own radius - a small fraction of it at shallow depths. The branch meant to relieve crowding therefore put over a thousand nodes on a circle with room for a dozen, overlapping them completely: worse than the single ring it replaced. Rings were also sized for nodes the layout then skipped.
Required behavior: every ring distance is derived from the size of the cards it has to seat, not from a constant.
- The first ring clears the centre card: its radius is at least the centre's half-diagonal plus the neighbour's half-diagonal plus a gap.
- Adjacent rings are at least a card height plus a gap apart.
- Neighbours on one ring are at least a card width plus a gap apart, which is what a ring's seating capacity is computed from.
The compact and spacious styles set the gap - 40px and 160px - so they still differ visibly, and neither can place a card on top of another. The constants they used instead (a 300px first ring, 80px between neighbours in compact) were sized for the 200x120 default card. A workspace of 356x301 cards put all seventeen neighbours of a hub inside the hub's own card.
Placing an edge label
A label sits on the curve it belongs to, computed from the four points that define the curve.
curved returns exactly those four points. hyperbolic returns six - port, standoff, two control points, standoff, port - and its curve is the middle four. Matching only a four-entry path meant hyperbolic edges fell through to the polyline calculation, which treats the control points as places the line passes through, so the label drifted further from the curve the further those points reached.
Showing an edge's colour
The colour swatch shown as active is the edge's own colour, and only falls back to the colour its link type implies. Passing just the link type meant a recoloured edge highlighted the swatch for its type instead of its colour.
Searching inside a node
Match positions come from the text that is displayed. In view mode the highlight walks the rendered text nodes, so offsets taken from the raw markdown were shifted by the frontmatter block and by every syntax character, and the highlight landed on unrelated words. Edit mode was already correct, because the editor holds the body with its frontmatter removed.
Reducing edge crossings
Ports are spread along a node's side, and the order matters: where a side's port order does not match the direction its edges leave in, those edges cross each other for nothing.
After ports are assigned, they are reordered so neighbouring ports lead to neighbouring nodes. That pass existed, complete and tested, and nothing called it - so no graph ever had its crossings reduced.
Routing edges around nodes
An edge routes around a node rather than through it. The three-segment router checks each segment for obstructions and inserts a detour perpendicular to that segment: a horizontal segment detours vertically, and a vertical one horizontally.
Each of the three calls passed the opposite of the segment's own orientation, so a horizontal segment was offered a horizontal detour - which cannot clear a horizontal obstruction. Obstacle avoidance therefore never produced a waypoint, and edges ran straight through nodes at every zoom level.
Re-routing edges during an interaction
Required behavior: The edge memo notices every change to node geometry, including geometry the store never records.
- Positions are usually written to the store, which bumps
nodeLayoutVersion, and the memo keys on that. Neighbourhood mode is the exception: its positions are an overlay, deliberately never written, so leaving the mode restores the canvas exactly as it was. - Keyed on the version alone, the memo reported a hit on entering the mode and returned edges routed against the positions the nodes had left. On screen the edges became stubs radiating from the focus node while its neighbours sat elsewhere, unconnected.
- The key therefore carries a running total over the displayed positions. It costs one pass of arithmetic and no allocation - cheaper than the edge-id string already in the key - and it notices a move no version counter saw.
- The routing cache under the memo carries the same total. Keyed on the version alone it answered a fresh memo with the paths it had routed against the previous geometry, so the cards moved and their edges stayed where the cards had been.
Edges are re-routed when the graph changes, and live while a node is dragged, because the edges attached to it must follow. A cached path cannot do that.
Zooming is not such a case. Pan and zoom are one container transform, so edge geometry in canvas coordinates does not change and the cache stays correct. Counting zoom as a reason to re-route ran the most expensive path on every recompute while zooming.
Showing edges only around the focus
Above its own threshold, the canvas draws only the edges touching what is hovered or selected, plus one hop for context.
This was gated on the unrelated "hide all edges above N" setting, which defaults to 0 - so the path could never run in a default install, and a user who set that setting to 5000 silently also enabled hover-only rendering above 1500, which is not what the setting says.
When a threshold hides edges, the status bar says so: how many edges are hidden, which threshold hides them and what brings them back (hovering or selecting a note for the hover threshold, raising the setting for either). A canvas with no edges and no explanation reads as edges having been lost: a workspace that grew from 375 to 978 edges in an afternoon crossed a hover threshold of 500, and every edge disappeared at once.
How far out zoom goes
The zoom-out floor comes from the content rather than a constant. A fixed floor serves every workspace the same number, and the number that lets a very large layout fit lets a mid-size one shrink to an unusable smudge while a pan crosses hundreds of canvas pixels per mouse pixel.
Deriving it from the fit scale then went too far the other way. The floor was the fit scale less a small margin, capped at half scale, so in any workspace whose content already fits the window zoom-out stopped at 0.5 - which is also the default threshold at which cards collapse to titles. The furthest the view could travel was exactly the point where the collapsed overview begins, so that overview could not be reached at all and zoom-out appeared to stop for no reason.
The floor is therefore set well below the fit scale, and always below the collapse threshold. Whatever the content, the user can pull back far enough to read the graph as titles. The absolute bound still applies for a vast or unknown layout, which is what keeps the smudge from returning.
Semantic zoom collapse
Cards collapse to titles below a threshold the user can set. The collapse state is re-evaluated when the scale changes and when the threshold changes; watching only the scale left cards collapsed or expanded until the user happened to zoom, disagreeing with every other view of the same setting.
Repainting above the LOD threshold
The canvas repaints when anything it draws changes. The repaint watcher must therefore list every prop the draw function reads.
It omitted the highlighted-node set, which is derived from edges as well as nodes. Adding an edge while the selection stood still changed what should be drawn without scheduling a frame, so the canvas kept drawing the previous highlight set until an unrelated pan or zoom.
Graph size tiers
Three tiers change how the canvas renders as a graph grows: large, huge, massive, in that order. Each threshold is named once and the tiers ascend.
They were inline literals with "massive" at 800 nodes and "huge" at 1000, so a 900-node graph was massive but not huge, and the names carried no information about which came first.
Detail also drops by zoom
A count tier alone cannot decide how much detail to draw, because a small graph can be drawn at a zoom where none of that detail is legible. Two mechanisms therefore have a zoom tier alongside their count tier, and either one turning on is enough:
| Mechanism | Count tier | Zoom tier |
|---|---|---|
Bubble mode (isLODMode) |
more visible nodes than the LOD threshold | a default-width card would render under 30 screen px, with a floor of 20 visible nodes |
Single-path edges (useSimpleEdges) |
the large-graph tier | the 12px edge hit stroke would render under 4 screen px |
The floor keeps a sparse workspace as cards: a handful of nodes costs nothing to draw properly, and bubbling them would only take detail away.
Without the zoom tier the count tier could never fire for the case that needs it most. Fitting a workspace whose layout spans tens of thousands of canvas px puts every node on screen at once at a zoom near the floor, so viewport culling culls nothing and the node count never changes. A card is a composited DOM subtree of some sixty elements, and the compositor backs each one whatever its size on screen. Measured on a 215-node workspace with a 41,000 x 50,000 px layout: 6 GB of shared graphics memory and a kernel out-of-memory kill as cards, against 476 MB and a live application as circles. Card cost saturates by about 60 rendered cards, so the tier has to be reachable well below any node-count threshold.
Keeping the webview at 100%
The webview stays at 100% zoom. Only the canvas zooms, and it has its own controls.
WebKitGTK zooms the whole page on Ctrl+wheel from inside the widget, which cannot be prevented from the page: the DOM event's default action is not what performs the zoom, so preventDefault has no effect on it. Scaling the interface up put the toolbar and the zoom controls outside the window with no way back, because Ctrl+0 and Ctrl+plus/minus are already the canvas font scale, and once the chrome has scaled away no non-canvas surface is left to Ctrl+wheel over. WKWebView has no such gesture, so this is a Linux problem in practice.
Since the zoom cannot be prevented, it is undone: after any gesture that could have zoomed, the zoom level is set back to 1. Two properties make that workable.
- Every route is caught, not only the ones the guard can name. A page zoom changes the viewport, so the webview reports a resize; listening for the resize covers the wheel gesture, the hotkeys, and anything the platform does natively that never reaches the DOM.
- Resets are coalesced to one per frame. A wheel burst is dozens of events, and one reset per event would be one IPC call per event.
Arrowheads above the LOD threshold
An undirected edge has no arrowhead at any zoom level. Above the level-of-detail threshold the arrowhead-suppression flag was hardcoded to false, so an edge with directed === false drew no arrow at normal zoom and grew one as soon as the graph crossed the threshold.
A marker id is derived from a colour by removing everything that is not a letter or digit. Stripping only # assumed hex, and an rgba(...) colour produced an id whose spaces, commas and parentheses are invalid in both an id and a url(#...) reference, so arrowheads vanished while hovering a colour-tinted node. The marker definition and the reference derive the id from the same function, because they must agree.
The line under an arrowhead
Required behavior: An arrowhead ends the line: the visible line stops inside the base of the head, and the tip of the head marks where the edge arrives. A line keeps a constant thickness on screen, so in canvas units it widens as the view zooms out, while the head had a fixed size of 20 canvas units and was drawn over the last 20 units of the line. Zoomed out, the line was wider than the pointed end of the head and its square end showed through the tip, so a head looked like two barbs on a blunt bar. The head is therefore at least four widths of a highlighted line, and never smaller than the 20 units it has at normal zoom, and the stroked line ends a tenth of the way into the head. The tip lies the arrow offset beyond the end of the routed path, which for an edge arriving at right angles is the border of the node. Routing is unchanged: the routers, the hit area and the label use the full path, and only the visible stroke of an edge that carries a head is shortened, by moving the last point of the path back along its final direction. A final segment shorter than the head keeps a stub, so the head keeps its direction.
Rendering queued diagrams
A diagram render that arrives while another is in flight is queued with the container it asked for. Replaying the in-flight call's own container rendered that view twice and left the queued caller's diagrams unrendered.
Depending on what is supplied
A composable moves nodes through the collaborator it was given, not by reaching past it. pushOverlappingNodes mutated node objects and called the backend directly, while the same file used its injected updateNodePosition two functions away - so coordinate clamping, layout bookkeeping and persistence policy applied to every moved node except a pushed one.
Frames removed
Required behavior: The canvas has no frames. A frame recorded membership by a stored frame_id, so resizing or moving it let the box on the canvas and the membership drift apart: a node inside a frame's box might not belong to it, and a member might sit outside it. Grouping is expressed with tags, which name a group without claiming a region of the canvas (docs/design/remove-frames.md).
- On upgrade, a migration gives each member of a titled frame that frame's title as a tag, clears every
frame_idand empties theframestable. Node positions and Markdown files are not changed; the tags are written to the database only. - A title becomes a tag by the hashtag rule: lowercased; ä, ö, ü and ß transliterated to ae, oe, ue and ss; every other character outside
a-z,0-9,_and-replaced by a hyphen; hyphen runs collapsed; leading and trailing hyphens removed; cut to 50 characters. "Definitions (Art. 3)" becomesdefinitions-art-3. One rule serves the migration (Rust) and the agent'stag_nodes(TypeScript), and both are tested against the same cases. - The empty
framestable stays.nodes.frame_idhas a foreign key to it, and SQLite checks that key on every node insert even when the value is NULL, so without the table no node could be written. Removing the key means rebuilding thenodestable with foreign keys off, which risks cascading deletes into edges and storylines for no gain beyond an empty table. - Vault import places each folder's notes as a cluster; a refresh moves no node. A PDF's section nodes carry the paper's title as a tag, and a Zotero import can tag its citations with the collection name.
- Dragging a node no longer moves its file between folders: that happened only when a node was dropped into a folder frame.
- A gate test fails when frame code appears anywhere but the migration.
Measured at 260930 on a copy of the production database: 45 frames removed, 299 nodes gained a tag (20 members already carried their frame's tag), node, edge and storyline-membership counts unchanged, no node moved.
Persisting a gesture
A gesture costs one write per thing it moved, not one per event. Dragging or resizing updates positions and sizes in memory while the pointer is down, and the final values are stored once when it ends.
Resizing wrote both position and size on every pointermove, so dragging a corner across the canvas issued hundreds of backend calls for one resize. The node drag path already had the mechanism - skipPersist on the update, a flush at the end - and the resize path did not use it.
Telling a click from a drag
A click is not a drag. An undo step is recorded only when the pointer actually moved a node.
The set of dragged nodes was derived from the multi-drag baseline, which is captured on pointerdown before any movement. So a plain click with several nodes selected ran the whole drop path - for a gesture that moved nothing. A single-selection click took a different branch, so the two behaved differently as well.
Persisting an interrupted drag
A drag updates positions in memory and stores them once when it ends, so a drag costs one write per node rather than one per frame.
A drag can end without a pointer release: the pointer is cancelled, the window loses focus, or the button comes up unseen. Those paths store the positions reached, the same as a normal release. Clearing the drag state without storing left the node where it was dropped on screen and back at its origin on the next load.
Syncing wikilink edges
The backend resolver understands folder path links and #section anchors as well as plain titles. The local resolver matches exact titles only, so it is a fallback for running without a backend, never for a backend that failed.
Treating any rejection as "no backend" ran the local resolver against edges the backend had created, which it could not see and therefore deleted. A single transient failure destroyed real edges, with nothing reported. Whether a backend exists is checked directly, and a backend that fails leaves the edges exactly as they were.
Import resolves wikilinks with the same backend resolver as the sync, and canvas navigation uses the same frontend resolver as link rendering. Each carried its own copy, so a link could lead to a different node depending on where it was followed.
Changing an edge's type
An edge's type is stored before the change is shown. The unique constraint covers source, target, and link type together, so setting a type that already connects the same two nodes fails, and the message says so.
Showing the change without storing it left the edge reverting on the next reload.
Reading frontmatter on either line ending
A file written on Windows, or by any tool that writes CRLF, opens with ---\r\n. Testing for ---\n alone read such a file as having no frontmatter, which had two consequences: writing the file back dropped its metadata, and content that carried its own block was given a second one. One function answers "does this open with a frontmatter block", and every caller uses it.
The closing fence is a line that is exactly ---, so a --- inside a value does not end the block early.
Exporting an OKF bundle
An exported document carries exactly one frontmatter block: the one built for the export, from the node's current metadata.
A file-backed node's content already holds a block, because Nodus writes one into the file. Prepending without removing it produced two, and every reader treats the second as body text. The export strips whatever block the content opens with, then writes the block it built.
One schema, however you got there
A database created by a fresh install and one upgraded through every migration must end up with the same schema. Two mechanisms broke that:
- Migration 008 rebuilds the
edgestable with a three-column unique constraint and astoryline_idforeign key. Its guard checked only the constraint. A fresh install gets that constraint from 001, which cannot referencestorylinesbecause 002 creates that table afterwards, so 008 was skipped and the foreign key andidx_edges_storylinewere never created. The guard now requires both, so 008 runs wherever either is missing. - The
framestable was defined in 001 and again in 007. Only the first definition applies:CREATE TABLE IF NOT EXISTSfinds the table already there and does nothing. The two disagreed on nullability, default sizes, and the delete behaviour of the workspace foreign key, so 007 described a schema no database has ever had.
A dangling storyline reference follows from the first of those. Without the foreign key, deleting a storyline leaves edges pointing at a row that is gone, and storyline-filtered queries return them.
Two gates hold this: no table may be defined by two migrations, and no migration file may go unapplied.
Splitting text into chunks
Long text is split into overlapping chunks for the model, breaking at a paragraph, sentence or word boundary so a chunk does not end mid-thought. Each chunk starts a little before the previous one ended, so context carries across the boundary.
The start of the next chunk must advance by at least one character. An overlap at least as large as the break point resolved to a start of zero, which left the remaining text unchanged and the loop running forever - a hang rather than a slowdown, with no output and no error.
Validating caller-supplied paths
A path the interface names is not a path the user chose. Commands that act on the filesystem using a path from the webview must check it against the workspace vaults before touching it, using validate_path_in_workspace for an existing path or validate_target_dir_in_workspace for a directory that may not exist yet.
The rule applies to the path a command stores as well as the path it reads: a command that records a node's file path without validation hands an unchecked path to every later command that trusts it.
Three cases are exempt, because the vault path is the thing being chosen:
- Registering, importing or refreshing a vault takes the folder the user picked. Validating it against the vault list it is about to define is circular.
- These commands are reachable only from a folder dialog.
The exemptions are listed with their reasons in the gate, so a new command cannot join them silently. Everything else fails the gate: a command with a caller-supplied path parameter that performs a filesystem operation must call a validator.
Dependency advisories
Dependencies are checked against the RustSec advisory database as a release gate. The dependency bounds already cover day-to-day drift, so a per-push check would spend CI minutes on a constraint that is already enforced statically.
- The gate fails on any advisory that is not listed in
src-tauri/.cargo/audit.toml. - Each accepted advisory records why it cannot be fixed here. An entry kept after its dependency is upgraded hides a real advisory behind a stale exception, so the list is reviewed on upgrade.
- The advisory database is fetched from an outside service. When the fetch fails the gate reports "not checked" and passes: someone else's downtime is not a finding about this code.
A created plan is presented
Whether the user sees a plan must not depend on the model making a second call. The prompt asks for request_approval() after create_plan(), and when the model skipped it - or called create_plan twice and then described the plan in prose - the plan existed in state with no dialog and nothing to approve, so the user was asked "would you like me to proceed?" in chat text they could not edit.
- Creating a plan opens the approval dialog, where its steps can be edited, added to, removed, approved or rejected.
request_approval()remains for the model to call and opens nothing that is already open.- Creating a second plan replaces what the dialog shows rather than opening another.
- A click outside the dialog does not close it. The dialog appears when the agent has a plan, often while the user is clicking on the canvas, and closing only hid it: the plan stayed pending with no way to reopen it, so the agent waited for an answer the user could no longer give. It closes through its close button or Escape.
One creator per plan
A plan is created once, by whoever handled the create_plan call. The marker processed after the call must not create it again: the second creation replaced the real plan with one holding no steps, requestApproval refuses a plan with no steps, and so no approval dialog appeared. The agent reported that it was waiting for approval on a plan the user was never shown, then retried and wrote the plan into the chat as prose instead.
- A
__CREATE_PLAN__payload that names an existing plan is passed through untouched. - A payload carrying steps and no plan identifier is created, which is the path where nothing has created it yet.
- Requesting approval twice for the same plan opens one dialog.
Saving edits when the open node changes
Both editors debounce writes, so a write can still be pending when the node being edited changes - clicking a wikilink in the fullscreen view swaps the open node within the debounce window. A pending write belongs to the node whose keystrokes armed it, not to whichever node is open when it fires.
- A scheduled write records the node it was armed for, along with the title and body to store.
- Changing the open node flushes the pending write first, then loads the new node.
- Closing the editor flushes rather than drops.
- An editor opened for one field, such as a date, a tag or the link picker, belongs to the node it was opened on. Changing the node closes it without writing, so nothing typed for one node is written into another.
Without this, the write compares the new node's stored text against the previous node's buffer, finds no difference to make, and the previous node's last keystrokes are lost with nothing reported.
Enforcing the file size limit
The 1000-line limit existed only as prose in the project rules, and seven files had grown past it. A rule with no gate erodes silently, so the limit is enforced as a ratchet: the files already over it are recorded with the line count they had when the gate was added, and the gate fails when any of them grows or when a new file crosses the limit.
- A recorded file may shrink freely; shrinking past 1000 lines removes it from the list.
- A recorded file that grows fails the gate, so splitting is the only way forward.
- A file not on the list may not cross 1000 lines at all.
- The gate covers every tree of shipped code:
src/,src-tauri/src/and the MCP server package'spackages/nodus-mcp-server/src/.
This enforces the limit from where the codebase actually is rather than blocking every commit until seven files are split.
Log level
Twenty-five call sites called logger.debug(). None could ever emit: the threshold was info in development and warn in release, and nothing could set it lower, so every one of those messages was written and never seen. Detail nobody can turn on is not logging.
- The threshold defaults to
infoin development andwarnin a release build. - Settings > General > Advanced selects the threshold, including
debug. - The choice persists across restarts, so a user reproducing a problem is not re-selecting it after every relaunch.
- Changing the threshold takes effect for subsequent messages without a reload.
Persisting animated positions
Required behavior: A layout animation moves nodes for the duration of the animation; only where they land needs storing. Writing every intermediate position issued one database write and one IPC call per node per frame - roughly 18,000 for a 600ms animation of 500 nodes, of which 500 mattered. This is the same defect the drag path already solved with skipPersist, and the animation must use it too.
- Frames update in-memory positions only.
- A frame applies all of its positions as one batch: the moving nodes are looked up through one map and the layout version changes once. Applied one node at a time, every write searched the whole node list and announced its own layout change; with 333 cards moving in a 1000-node workspace that cost 10.9 ms per frame (261001), two thirds of a 60 fps frame before any card moved on screen, and the animation stuttered. As one batch the same frame costs 0.8 ms.
- The final positions are persisted once, when the animation completes.
- An animation superseded by another persists what it reached, so a position is never left unsaved.
Choosing a workspace
Required behavior: The workspace list is searchable, ordered by what the user touched most recently, and says how large each workspace is.
- The workspace scopes the canvas, search, the agent's context and file sync, so choosing one is the most consequential control in the toolbar. A plain dropdown of every workspace in creation order stops being usable somewhere around a dozen entries; this installation has thirty-seven.
- Typing filters the list by name, ignoring case and accents. The keyboard alone can reach any workspace: arrows move, Enter switches, Escape closes.
- Recently opened workspaces come first, in the order they were last opened, and the rest follow alphabetically. Recency is the only ordering that reflects how the list is actually used, and alphabetical order underneath keeps a workspace findable when it has not been opened before.
- Each row carries its node count, taken from the nodes already in memory rather than a query, so the list says which workspaces hold work and which are empty.
Workspace settings
Required behavior: The workspace editor offers only what is stored, and stores everything it offers.
- Creating a workspace stores no vault path. The vault is chosen in the workspace editor, which stores it there. The create command carried a vault path field that no caller ever filled, under a key the backend did not read, and both are gone.
- Restoring a deleted workspace restores its vault path and its sync setting.
- The description field is removed. No workspace description is stored anywhere, so text typed there was discarded on save.
- A rename that fails is reported. A new workspace is opened only after it has been created.
What a card's chip row shows
A card's chip row surfaces the OKF frontmatter, which is stripped before the body is rendered and so appears nowhere else on the card. That is the date and the status.
Tags were shown there too, and a tag is not frontmatter: it is written as a #hashtag in the body, the body is what the card renders, and a sync keeps the node's tag list in step with it. A tag shared by more than one note is also drawn as a tag node with edges to the notes carrying it. So a chip was a fourth appearance of a fact already in the text, already in the preview and already on the canvas, and it took the bottom strip of every card that had one - space the preview is otherwise sized to use.
- Tags are read in the text, and followed through their tag nodes.
- Tags are edited by typing in the body. A chip that wrote the tag list directly could set a tag the body did not contain, which is the same duplication in the other direction, and only the body keeps the note and its file agreeing.
- A tag used by a single note still describes that note and is kept. What did not scale was drawing a node for it: one per distinct tag put 606 tag nodes into a workspace holding 360 real ones.
Connecting tags on load
The pass that connects tagged notes walks the workspace that is open, because that is the workspace whose edges are loaded.
Its guard against making a connection twice checks the edges the application holds, and those are fetched for one workspace. The loop walked every node in the database. So for a note in any other workspace the guard saw no edge, concluded the connection was missing, and asked for one that already existed - which the database refuses, once per note, on every load. A vault of several workspaces produced a wall of failures saying a link could not be created, for links that were all present already.
- The loop and the guard see the same workspace. A note elsewhere is connected when its own workspace is opened and its edges are loaded.
Tag nodes belong to a workspace
A tag node is looked up in the workspace of the node being tagged. Reusing a tag node from another workspace linked nodes across workspaces, where no view shows the link.
Tags are not read from code
A #word inside a fenced code block or an inline code span is literal text, not a tag.
The scan read the whole body. A Mermaid diagram styles its nodes with colour codes (classDef process fill:#f3e5f5), so each colour became a tag on the note, and two notes with diagrams shared those tags and were joined through tag nodes named after colours.
- A fenced block opens on a line starting with three or more backticks or tildes and closes on a line of the same character at least as long; an unclosed block runs to the end of the text. An inline span is delimited by equal runs of backticks and does not cross a blank line.
- Tags recorded before this rule are withdrawn when their workspace is loaded or switched to: a recorded tag that the note's text holds only inside code leaves the note, its
taggededge is deleted, and the tag node goes when nothing else uses it. Measured on the vault this was written against (11,471 notes with content): 16 recorded tags on 5 notes. - The withdrawal is limited to the open workspace, whose edges are loaded, so a tag and its edge leave together. It runs before the pass that connects tags, so that pass does not connect a tag about to be withdrawn.
- A tag added by hand that the text also holds only inside code is withdrawn as well; the two cannot be told apart from the stored data.
One tag node under concurrent callers
A tag has one tag node per workspace however many callers ask for it at once.
Finding a tag node and creating it when absent are two steps with a wait between them: the node exists in the application only once the database has answered. Two passes that connect tags can run at the same time - loading a workspace starts one, and the body scan that finishes after it starts another - and an edit can tag a note while either runs. Each caller looked, found nothing, and created a node, so a tag new to the workspace got two nodes with the same title, one holding a single edge and the other the rest.
- A creation in progress is shared: a caller asking for a tag whose node is being created waits for that node instead of creating another.
- Creations are distinguished by workspace and by the tag compared without regard to case, the same identity the lookup uses.
Creating a comment
A comment is created the same way from the storyline panel and from the reader. It carries the type the user chose and the meta header that records it, and it is anchored into the node it comments on. The panel wrote neither and did not anchor, so a question or a todo created there was shown as a plain note.
Checking outbound URLs
Required behavior: A URL that the interface or the agent asks the backend to fetch is checked before any request is made.
- Only
httpandhttpsare allowed. - Link-local addresses and cloud metadata services are refused however they are written: as an IPv4 literal, an IPv6 literal, an IPv4-mapped IPv6 literal, or a hostname that resolves to one. The check applies to the address the request connects to, so a name that resolves differently on a second lookup cannot pass it.
- Localhost stays reachable, because local model providers (Ollama, LM Studio) and the Zotero API run there.
Requests to outside services go through the backend
Required behavior: A request to a service outside the application is made by the backend HTTP command, not by the web view.
The web view of the packaged application connects only to the hosts its content security policy lists. The Zotero Web API and the Wikipedia requests of the agent's research tool were made with the web view's own fetch to hosts the policy does not list, so they worked in a development build, which carries no such policy, and were refused in the installed application.
- The frontend reaches outside services through one function, which hands the request to the backend when running in the packaged application.
- A direct
fetchis permitted only in the files a gate test lists, each for a host the policy names: the local Ollama server and the Semantic Scholar API.
Workspace scoping for MCP connections
Required behavior: A connection scoped to a workspace sees that workspace, consistently. Scoping only the list getters produced a store that contradicted itself: a listing returned the target workspace's items while a lookup of those same ids failed, because it resolved against whichever workspace the user happened to have open.
- Single-entity lookups resolve within the connection's scope, exactly as the list getters do. An id a scoped listing returned must be usable by every operation that takes an id.
- A scoped store derives its lookups from its own scoped collections rather than from the application's, so the two cannot drift.
- A write lands in the connection's workspace, as its reads come from it.
- Storylines are scoped like nodes and edges. They were read and written through the application's open workspace throughout, so a scoped connection listed the wrong workspace's storylines and created its own in the wrong place.
- A store that scopes some of its methods and inherits the rest cannot be read for what it does. Each method is either scoped or recorded as independent of the workspace, and a gate holds the list.
Deleting a merged wikilink edge
Required behavior: A merged wikilink edge is undirected because both nodes link to each other; it stands for two wikilinks, one in each file. Deleting it removed the link from the source's content only, so the next wikilink sync saw the surviving link in the other file and recreated the edge - the user deleted it and it came back.
- Deleting an undirected wikilink edge removes the wikilink from both nodes' content.
- Deleting a directed one removes it from the source only, as before.
- Removing the wikilink is an edit like any other. It is recorded as an undo step and saved through the store, so the node's checksum stays current and the watcher does not read the file back as an outside change.
Agent log contents
Required behavior: The log is where the user looks to see what the agent actually did, so every tool call appears there: its name, a short summary of its arguments, and whether it succeeded. Recording tool calls only in the chat transcript left the log showing prompts and warnings but never the actions between them.
- One line per call, with arguments summarised rather than dumped, so a run of fifty calls stays readable.
- A failed call is marked as failed with its error, since a silent line reads as success.
- Clearing the log is drawn with a bin, in the standalone log panel and in the agent panel alike; a cross means closing. The standalone panel drew the same cross for both, side by side, so the header read as two close buttons and the one that discards the log could not be told from the one that hides it.
Tool reachability
Required behavior: A registered tool that no mode exposes is dead code, and a prompt that documents such a tool is worse - it instructs the model to call something the request does not contain. Both existed: 27 of 71 registered tools reached no agent surface, and the system prompt described five of them to the model in detail.
- Every registered tool is reachable from at least one agent mode, or is listed as deliberately unexposed with the reason.
- A tool the system prompt documents must be reachable, without exception: promising a capability that cannot be called is the defect the ledger exists to prevent.
- A gate compares the registry against the mode whitelists and the ledger, so a tool added without exposure fails the build rather than sitting unused.
Plan approval summary
Required behavior: The approval dialog exists so the user can see a plan's effect on the graph before consenting to it. A summary that understates that effect is worse than none, because it invites consent the user would otherwise withhold.
- A count of nodes is only stated when the plan names them. A step that names its targets contributes those targets; the summary says how many and can list them.
- A step that names no targets has an unknown scope: it may touch one node or every node in the workspace. The summary says the scope is unstated rather than counting the step as one node - counting steps and labelling them nodes is how a step rewriting 317 nodes was presented as editing one.
- Counts and scope warnings are derived from the same source the executor uses, so the summary cannot drift from what the plan does.
Anchored nodes
Required behavior: A note about a passage belongs at that passage. A comment that floats between sections says only which node it concerns, leaving the reader to work out which sentence provoked it - and the position is lost as soon as the text is edited anywhere above it.
- The anchor is a
[[wikilink]]written at the point in the text it refers to. The text is the anchor, so it survives editing here, in Obsidian, or in any other editor, and needs no stored offsets that a later edit would silently invalidate. - The reader shows every wikilink as an inline link, at every width; the links sidebar shows the linked node beside it. Links used to expand into callouts carrying the linked node's content at full width, which inserted whole notes mid-sentence: a chapter referenced inside a parenthesis opened inside the parenthesis.
- A link whose target does not exist is marked missing, as it is elsewhere.
- The links sidebar gives every link the reader shows one card, level with it. Cards are built from the rendered links, not from the section's Markdown: they were placed by counting rendered links, so any difference between the two counts misplaced cards out of view.
- The cards scroll with the text: they sit in the same scroll container, so the browser moves both together and no script runs while scrolling. Cards moved by script in response to scroll events trailed the natively scrolled text. Links are measured when the rendered content changes, relative to the text column, where their offset does not depend on scrolling.
- Creating a comment writes such a link at the anchor point, so a comment is an anchored node rather than a separate kind of thing.
Reading a single node
Required behavior: Reading is currently only reachable through a storyline, so a node that belongs to no storyline cannot be read at all.
- Any node can be opened in the reader on its own, showing its text at full width, using the same reader the storylines use rather than a second implementation.
- The single-node reader is reachable from the node itself on the canvas.
- Storyline operations are unavailable while a single node is being read, because there is no storyline for them to act on. Adding, removing and reordering reached a placeholder storyline built from the node, and the refetch that followed replaced the node with the storyline read before it.
- The scroll position of a single node is not remembered under a storyline. It was saved under the storyline read before it.
- Opening a storyline in the reader clears any single node being read. It was cleared only by the reader's close button, so reading one node and then opening a storyline showed that node again instead of the storyline.
Layout of a selection
Required behavior: A selection is an instruction. Every node the user selected takes part in the layout, and a node that is silently left where it was reads as the layout being broken - which is how it looked when nodes belonging to a frame were filtered out of a selected layout without a word.
- With a selection, exactly the selected nodes are laid out.
- Without a selection, the whole graph is laid out.
Hierarchical layout spacing
Required behavior: A hierarchical layout places nodes as close together as they can go without overlapping. Nodes in the same rank are 24 px apart, the gap the grid layout packs with, and consecutive ranks are 60 px apart, which keeps the edges between them visible.
- Gaps are measured between card borders. The layout sizes each node by its stored width and height (default 200 x 120), which is the size the card renders at, so no gap is needed to absorb a size mismatch.
- The spacing is defined once, in the layout module. The canvas layout command does not pass values of its own.
The gaps were 150 px within a rank and 360 px between ranks, three times the height of a default card between consecutive rows. The canvas command also passed its own values over the module's defaults, so the layout had two spacings and the one in effect was not the one the module declared.
Edge handles
Required behavior: An edge that is live along its whole length fires during ordinary mouse travel. In a window that does not fill the screen the pointer crosses a border constantly - reaching for another application, the dock, the desktop - and every crossing opened a panel the user did not ask for. The gesture has to be aimed to count.
- Each edge is active only over a handle centred on that edge, not along its full length. A push registers when the pointer is inside the edge band and within the handle's span.
- The handle spans a fraction of the edge, bounded so it stays aimable on a small window and does not become an entire edge on a large one.
- Handles are drawn on screen. A gesture that requires aim must show where to aim, and a visible handle answers the discoverability problem the first-run coach addresses in words.
- The handle geometry has one definition, used by both the gesture and the drawing. A handle drawn anywhere other than where the gesture listens is worse than no handle at all.
- Handles never take pointer events. They mark a region; the canvas underneath stays fully interactive.
- The gesture is live only while the window is in full screen. A window edge that is not a screen edge is crossed constantly - reaching for another application, another window, the desktop - and aiming at a handle does not stop the crossing from being accidental. In full screen the window edge is the screen edge, the pointer stops there, and a push against it can only be deliberate. Windowed, the panels open from their toolbar buttons, as the timelines sheet already does. The handles are drawn only when the gesture is live, so an inert edge is never marked.
- Only the left and right edges carry a gesture. The bottom edge raised the timelines sheet after a short dwell and the top edge closed it again. A dwell is not an aimed gesture: panning the canvas towards the bottom of the window rests the pointer in the band for far longer than the dwell, and the sheet unfolded during ordinary work. Narrowing the gesture to a mouse fixed it only for pen and touch, because a mouse is exactly what pans the canvas. A sheet that covers the lower half of the screen is too disruptive to open on an ambiguous signal, so it opens from a toolbar button - a control the user can see, aim at, and choose. The edge stepper still supports a bottom and top step; nothing is wired to them.
First-run gesture coach
Required behavior: The edge-step gestures are the canvas's primary navigation and are invisible: nothing on screen suggests that pushing the pointer against a screen edge reveals the storyline overview, or that the left edge reveals the agent. A user who never discovers them never finds those features at all.
- After onboarding, a coach teaches one gesture at a time, in the order a new user would need them: storylines (right edge), agent (left edge).
- A lesson exists only for a gesture that exists. The coach taught the bottom-edge timelines dwell; when that gesture was removed the lesson could never be performed, and the tour would have stalled on it forever. Removing a gesture removes its lesson in the same change. Timelines are not taught, because a visible toolbar button needs no teaching.
- A lesson advances only when the user actually performs the gesture, not on a timer or a click. Reading about a gesture is not learning it, and the coach exists precisely because the gesture is hard to guess.
- The coach can be skipped at any point, remembers that it has been completed or skipped, and never appears again.
- It listens to the same edge-step events the application already uses, so it cannot drift from the gestures it teaches: if a gesture stops firing, the lesson stops advancing.
Updates
Required behavior: An installed copy checks for a newer release on startup and tells the user, rather than leaving them frozen on whatever version they first downloaded. Without this, every fix reaches only people who happen to visit the download page again.
- The check runs once per launch, in the background, and never blocks the canvas. A failure to reach the network is silence, not an error: being offline is the normal case for a local-first application, not a fault to report.
- When a newer version exists the user is told what it is and chooses whether to install; nothing downloads or restarts on its own.
- Update manifests and binaries are served from the same R2 bucket the download page uses, and every artifact is signed. An unsigned or mis-signed payload is refused by the updater, so a compromised bucket cannot push code to users.
- The setting is user-controllable and persisted: automatic checks can be turned off entirely in Settings > General, in which case the app never contacts the update endpoint.
Settings
Settings modal with six tabs: General, Appearance, Canvas, AI, Citations, Integrations.
General: - Language selector (en, de, fr, es, it) - Collapsible About & License section - Collapsible Advanced section with workspace diagnostics (scan for node counts per workspace, recovery)
Appearance: - Theme selection grid (built-in + custom), delete custom themes - Display options
Canvas: - Snap to grid toggle - Grid size (px) - Edge style (straight, orthogonal, diagonal, curved, hyperbolic)
AI: - LLM Features toggle (show/hide AI prompts) - Provider selection (Ollama, OpenAI, Anthropic, OpenAI-compatible) - Streaming toggle (optional) - API key, base URL, model selection - Max tokens, context window, timeout - Neighbor context limit - Web search API key (Tavily) - System prompt customization
Citations: - Zotero connection (library access, collection import) - Citation import via BibTeX / CSL-JSON
Integrations: - MCP server controls
Content Rules (System Prompt): - Title = label, Content = substance - No meta-commentary - Be concise: data, definitions, or markdown only
MCP Server
Workspace scoping: each connection can target its own workspace via list_workspaces / set_workspace (id or name), independent of the workspace open in the app — multiple agents can work different workspaces in parallel. An unscoped connection follows the open workspace. Scoped reads serve that workspace's nodes, edges, and storylines; node creation lands there; scoped changes stay off the user's undo stack.
Connection trust: a client's first connection requires user approval in the app. On approval the server issues a random token whose SHA-256 hash is stored in the mcp_trusted_clients table; the client persists the token (~/.nodus/mcp-token) and presents it via an authenticate request on later connections, which are then approved without a prompt. Clients that present no valid token get the approval prompt after a short grace period or on their first request. Settings > Integrations shows the number of trusted clients and can forget them all, which revokes every stored token.
resources:
- graph://nodes/{id}
- graph://workspaces/{id}
- graph://canvas/{workspaceId}
tools:
- create_node
- update_node
- link_nodes
- search_nodes
- get_context # Returns relevant nodes for a query
- get_neighbors # Returns connected nodes
Monetization
Open Core Model
| Component | License |
|---|---|
| Desktop app | Open source (trust, academics) |
| Obsidian bridge | Open source |
| Local AI | Open source |
| Sync server | Proprietary |
| Team features | Proprietary |
| Enterprise features | Proprietary |
Pricing
| Tier | Price | Features |
|---|---|---|
| Free | EUR 0 | 3 canvas boards, local only, Ollama, no mobile |
| Pro | EUR 10/mo | Unlimited boards, EU sync, mobile access, PDF import, Typst export |
| Team | EUR 15/user/mo | Shared workspaces, real-time collaboration |
| Enterprise | Custom | SSO, audit, self-hosted, SLA |
Free Tier Limits (Critical for Conversion): - 3 canvas boards maximum - Local only (no cross-device sync) - No mobile access - Ollama AI allowed (local) - Obsidian bridge allowed
Pro Value Prop: Not just "sync" — Cross-device intelligence - Desktop LLM summarizes node → appears summarized on phone - Seamless mobile capture → lands on desktop canvas
Competitive Pricing
| Competitor | Price | Our Advantage |
|---|---|---|
| Heptabase | EUR 12-18/mo, no free tier | Limited free tier + open source |
| Notion | EUR 10/mo, US cloud | EU sovereignty |
| Obsidian Sync | EUR 8/mo | More features, graph-first |
Revenue Target
Full-time: EUR 15K/month
| Year | Pro | Team | MRR |
|---|---|---|---|
| 1 | 500 | 50 | EUR 5,750 |
| 2 | 1,500 | 200 | EUR 18,000 |
| 3 | 3,000 | 500 | EUR 37,500 |
Technical Stack
| Layer | Technology | Rationale |
|---|---|---|
| Frontend | Vue 3, TypeScript | Proven, ecosystem |
| Canvas | DOM + SVG + Canvas 2D | DOM cards for text and editing, SVG edges, a 2D canvas above the LOD threshold |
| Desktop | Tauri v2 | Small binary (~10MB), Rust security, native WebView |
| Database | SQLite via sqlx |
Embedded, WAL mode, no server to run |
| Content | .md files | Text content in Markdown files, NOT in SQLite |
| State | Pinia | Vue standard |
| Math | @myriaddreamin/typst.ts | Typst WASM, sub-second rendering |
| Editor | Plain textarea overlay |
Markdown edited as text; rendered separately, with Typst and Mermaid blocks |
| Layout | D3-force | Force-directed auto-layout on import |
| Edge Routing | Custom PCB-style | Lane-based routing with GridTracker, obstacle avoidance |
| File Watch | Rust notify crate + file locking |
Prevent corruption with Obsidian |
| Sync | Not implemented | Planned: CRDT merge of canvas positions only, never text |
| Backend | Rust (Tauri commands) | Local file, database and watcher work; no sync server exists yet |
| Hosting | Not implemented | Planned for sync: EU, GDPR-native |
Critical Architecture Rule
Separation of Concerns: - SQLite: Metadata, canvas positions, edges, tags - .md files: Actual text content (Obsidian compatible) - CRDTs (planned): Would sync canvas positions across devices only
Never store CRDT binary data in the same column as Markdown content.
Why No "Conflicting Copies" (Unlike OneNote)
OneNote creates duplicates because it syncs at file/section level. Nodus avoids this:
- Local-first: All edits happen locally. No internet needed.
- CRDT sync for positions (planned): would sync node x,y coordinates, NOT text.
- File locking: Rust acquires lock on .md when open in Nodus.
- Checksum detection: SHA-256 detects external changes.
Canvas rendering
As built. The canvas is drawn with DOM, SVG and a 2D canvas. There is no WebGL renderer, and no GPU rasterisation of nodes or edges.
| Layer | Technology | What it renders |
|---|---|---|
| Node cards | DOM components | Text, math, editing affordances |
| Edges | SVG | Connections, arrowheads, hit areas |
| Dense mode (over the LOD threshold) | Canvas 2D | Nodes collapsed to circles |
| Pan and zoom | One CSS transform on each layer, node cards included | Composited by the GPU; per-frame JavaScript is zero |
Why DOM rather than WebGL: editable text, text selection, accessibility and the existing math rendering all come free in the DOM and would have to be rebuilt against a WebGL renderer. The cost is that layout and paint run on the main thread, so the work scales with the number of visible elements.
Pan and zoom (required behavior): Every layer - frames, edges, and the node cards - lives inside a container that carries the single pan-and-zoom transform. A card's own style depends only on its canvas coordinates, never on the current scale or offset, so panning and zooming update one container style and composite on the GPU. The previous arrangement positioned each card in screen coordinates, which recomputed and patched every visible card's style on every frame: 24 ms per frame at 500 nodes, measured, against a 16.7 ms budget. Text crispness is unchanged: cards already applied scale() in their own transform, which rasterizes identically to a parent transform. A test holds the invariant that a card's style is independent of scale and offset.
How it scales instead of using the GPU:
- Viewport culling keeps off-screen nodes out of the DOM entirely.
- Above the LOD threshold (500 visible nodes by default) node cards are replaced by circles drawn into a single 2D context.
- Edges have a single-path fast mode, and a user-configurable threshold that hides them entirely.
Required behavior: this section describes what exists. An earlier version of it specified a PixiJS/WebGL renderer that was never built, while the main canvas component was named after it - and a real zoom optimisation was removed in the belief that the absent renderer made it unnecessary. Documentation that describes an intention as an implementation is worse than no documentation: every later decision reasons from it. A gate test fails if the source claims a WebGL or PixiJS renderer again.
Implementation Roadmap
Recommended Approach: Hybrid (Bottom-Up + Visual)
Given the priority on data integrity (no OneNote-style conflicts), but also need for momentum:
| Week | Focus | Deliverable |
|---|---|---|
| 1 | Rust backend | File watcher, checksum logic, SQLite writes |
| 2 | Canvas MVP | Canvas with mock nodes (hardcoded JSON) |
| 3 | Integration | Canvas reads from SQLite, displays real nodes |
| 4 | Editing | Inline text editing in nodes |
| 5 | Connections | Draw edges between nodes |
| 6 | Obsidian import | Parse vault, auto-layout, wikilink → edges |
Development Milestones
| Step | Task | Success Metric |
|---|---|---|
| 01 | Initialize Tauri v2 + Vue project | App opens in <0.5s |
| 02 | Implement Rust file-watcher for test folder | Adding .md file triggers console log |
| 03 | Basic canvas with draggable nodes | 100 nodes drag at 60fps |
| 04 | Integrate Typst WASM for math node | $a^2 + b^2 = c^2$ renders instantly |
| 05 | Build Obsidian link parser | [[Link]] creates edge on canvas |
| 06 | Implement checksum-based sync | External file edit updates node |
| 07 | Auto-layout on import | D3-force positions 100 nodes in <3s |
| 08 | Inline editing | Double-click node, type, save |
Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Sync method (planned) | Local SQLite + a CRDT layer | Granular merge of positions, no file-level conflicts |
| Data locality | Local-first | Instant editing, internet only for sync |
| File handling | Rust notify + locking | Prevents corruption when Obsidian open |
| Canvas renderer | DOM + SVG, Canvas 2D above the LOD threshold | Frame time measured by the render benchmark |
| Math renderer | Typst WASM + SVG cache | Sub-second, cached for performance |
Project Scaffolding
# Create Tauri v2 project
npm create tauri-app@latest nodus
# Choose: Vite + Vue + TypeScript
# Project structure:
nodus/
├── src/ # Frontend (Vue)
│ ├── components/
│ ├── canvas/ # Canvas logic
│ ├── stores/ # Pinia state
│ └── App.vue
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── main.rs
│ │ ├── watcher.rs # File watcher (notify crate)
│ │ ├── database.rs # SQLite operations
│ │ └── commands.rs # Tauri commands
│ ├── Cargo.toml
│ └── tauri.conf.json
└── package.json
Key Crates (Rust)
| Crate | Purpose |
|---|---|
notify |
File system watcher |
fs2 |
File locking (cross-platform) |
sqlx or tauri-plugin-sql |
SQLite operations |
sha2 |
Checksum calculation |
uuid |
Node ID generation |
serde |
JSON serialization |
y-crdt (planned) |
CRDT bindings for position sync; not a dependency yet |
File Locking Workflow
User opens node in Nodus
↓
Acquire SHARED lock on .md file (read)
↓
User starts editing (double-click)
↓
Try to upgrade to EXCLUSIVE lock (write)
↓
┌─────────────────┬──────────────────────────┐
│ Lock acquired │ Lock failed │
│ → Edit enabled │ → Show: "File is being │
│ │ edited in another app" │
└─────────────────┴──────────────────────────┘
↓
User saves → Write to .md → Release lock
Important: Do NOT lock during initial import checksum scan — only during active editing.
Editing a node on the canvas takes this lock, as editing in the reader does, through one composable for both. A second request for a lock Nodus already holds on the same node succeeds: Nodus is not another application, and reporting it as one locked the user out of their own note.
CRDT to canvas integration (planned)
The sync layer below is a design, not shipped code. Since it would sync canvas positions only (never text):
CRDT Document (Backend)
│
│ Maps: NodeID → (x, y, z_index)
↓
Rust updates SQLite nodes table
│
│ Tauri event: "node-position-changed"
↓
Vue Frontend receives event
│
↓
The canvas layer updates node positions
Hybrid Rendering Workflow
┌─────────────────────────────────────────────┐
│ CANVAS │
│ ┌─────────────────────────────────────┐ │
│ │ Canvas layer (DOM + SVG) │ │
│ │ - Background grid │ │
│ │ - Edges/connections │ │
│ │ - Node containers (rectangles) │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ DOM Layer (HTML overlay) │ │
│ │ - <textarea> for editing │ │
│ │ - Positioned via CSS transform │ │
│ │ - Maps canvas coords -> CSS top/left │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Zoom threshold:
zoom > 0.5 → Show DOM text elements
zoom < 0.5 -> cards collapse to circles (2D canvas)
Coordinate mapping (canvas -> DOM)
function syncDOMToCanvas(nodeId: string, position: { x: number; y: number }) {
const domElement = document.getElementById(`node-${nodeId}`);
const screen = canvasToScreen(position, viewport);
domElement.style.transform = `translate(${screen.x}px, ${screen.y}px)`;
}
Multi-User & Collaboration Guidelines
Local-First Principles: 1. Local database is source of truth — sync is secondary 2. Network is optional — work is never trapped on one device 3. Partition data — per note/board/project, not one massive file 4. Smaller payloads — faster sync, graceful failures
Conflict Resolution Strategy:
| Approach | Pros | Cons | Use Case |
|---|---|---|---|
| CRDTs | Automatic merge, no data loss | More complex | Collaborative editing |
| Last-Write-Wins | Simple, fast | May lose data | Single-user sync |
Recommendation: CRDTs for content, LWW for metadata (positions, colors).
Collaboration-Aware UI: - Show activity indicators (who's editing) - Prevent conflicts through awareness, not just auto-merge - Optional: real-time cursors for shared workspaces
Task & Project Management Integration
Tasks and projects are nodes in the graph, not separate silos.
Design Principles:
- Actionable Integration: Todos linked directly to notes and projects
- Minimalist Capture: Fast entry, auto-organization via tags
- Context-Based Views: Tasks grouped by project, not just flat list
- Daily Notes: Canvas for daily thoughts, sort into tasks in evening
Node Types for PM:
| Type | Purpose | Properties |
|---|---|---|
task |
Actionable item | due_date, status, assignee |
project |
Container | progress, deadline |
milestone |
Achievement marker | target_date |
daily |
Daily note canvas | date |
Task States:
Mobile Strategy: The Capture Bridge
Phase 1: Do NOT rebuild canvas on mobile. Build a simple PWA for capture only.
Mobile PWA Features:
- Create new .md files in synced folder (Dropbox/Nextcloud initially)
- Tag and title new notes
- Voice-to-text capture
- Photo → OCR → node
Desktop Integration: - Desktop detects new files via watcher - Auto-adds to SQLite with default position - User arranges on canvas later
This justifies Pro tier — mobile capture only works with cloud sync.
Go-to-Market
Phase 1: Community (Months 1-6)
- Open source desktop app
- "Stop managing windows" messaging
- Obsidian subreddit, Academic Twitter
- Blog: "LaTeX to Typst migration guide"
- Conference: local academic meetups
Phase 2: Monetize (Months 6-12)
- Launch Pro tier
- First 100 paying researchers
- Case studies: "How I wrote my thesis in Nodus"
- University IT outreach
Phase 3: Scale (Year 2+)
- Team tier
- Enterprise pilots
- Institutional licenses
- EU grant applications (Horizon Europe)
Risks and Mitigations
Business Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Empty canvas overwhelms users | High churn | Nail auto-layout, provide templates |
| Obsidian adds canvas editing | Competition | Move fast, deeper Zotero integration |
| Typst adoption slower than expected | Reduced differentiation | Keep LaTeX fallback |
| Mobile gap loses users | Incomplete solution | PWA capture app early |
| Enterprise sales cycle too long | Cash flow | Focus on self-serve researchers first |
| Free tier too generous | No conversion | Limit free to 3 canvas boards |
Architectural Risks (Critical)
1. The Sync Trilemma
Problem: SQLite (data) + a future CRDT layer (collab) + Obsidian (.md files) creates conflict risk. Only the first and third exist today; the mitigations below are the rules a sync layer would have to follow.
If user edits in Nodus (SQLite) AND Obsidian (.md) simultaneously → data corruption.
Mitigations:
| Strategy | Implementation |
|---|---|
| File Locking | Rust backend acquires lock on .md when open in Nodus |
| Separation of Concerns | SQLite for metadata/positions only; .md files for content |
| CRDTs for Canvas Only | A CRDT layer would sync node positions, NOT text content |
Critical Rule: Do NOT store CRDT binary data in the markdown_content column. Keep text in .md files.
2. Obsidian Canvas Drift
Problem: Obsidian Canvas (.canvas) has x,y coordinates. Nodus has x,y. If user moves nodes in Nodus, Obsidian Canvas becomes outdated.
Mitigation: Build an Obsidian Plugin that reads/writes x,y from Nodus database, or vice versa.
Also: Obsidian uses folder structure; Nodus canvas is flat. Import places each folder's notes as a cluster; the folder stays in each node's file path.
3. Text editing
Editable text, selection and accessibility come from the DOM, which is why the canvas renders node cards as DOM elements rather than into a WebGL context. The cost is that layout and paint run on the main thread and scale with the number of visible cards; viewport culling and the LOD threshold are what keep that bounded.
Success Metrics
Product
- Daily active users
- Nodes created per session
- Connections created per user
- Obsidian vaults imported
- Typst equations rendered
Business
- MRR
- Free → Pro conversion rate
- Churn rate
- NPS score
Quality
- Canvas performance (60fps with 1000 nodes)
- Import success rate
- Sync conflict rate
Open Questions
- Product name: "Nodus" sounds like a library. Consider: Synapse, Loom, Kinetic, Aura, Marrow, Lattice
- Mobile strategy: PWA capture app vs native? Focus on "add node" not full editing
- Zotero integration depth: Plugin vs direct API?
- One-time purchase: Offer perpetual license for desktop-only users?
- Academic discount: 50% for .edu emails?
Next Steps
Completed (Weeks 1-8)
- [x] Finalize product name → Nodus
- [x] Initialize Tauri v2 + Vue project
- [x] Set up LibSQL database with schema
- [x] Implement Rust file-watcher (notify crate)
- [x] Write checksum function (SHA-256)
- [x] Canvas with DOM cards, SVG edges and a 2D canvas above the LOD threshold
- [x] DOM overlay for text editing
- [x] Draggable nodes at 60fps
- [x] Connect canvas to SQLite
- [x] Inline node editing (DOM layer)
- [x] Obsidian vault import
- [x] Wikilink → edge parsing
- [x] D3-force auto-layout
- [x] Draw connections between nodes
- [x] Semantic zooming
- [x] PCB-style edge routing with lane separation
- [x] Undo/redo system with node deletion support
- [x] Multi-directional node resize (8 handles)
- [x] Neighborhood mode with configurable depth (1-5 hops)
- [x] Theme system with 4 themes and persistence
- [x] External links open in system browser
- [x] LLM agent with tool calling and queue manager
- [x] Multi-language support (en, de, fr, es, it)
- [x] Language selector in onboarding and settings
- [x] Unified file drop import (PDF, MD, BibTeX, ontology)
- [x] File locking mechanism (fs2 crate for cross-platform locks)
- [x] Integrity test suite (concurrent edit tests, checksum validation)
- [x] Typst backend rendering (Rust-side typst crate for math compilation)
- [x] Folder → Frame mapping (auto-create frames from Obsidian folders on import; folders become clusters since 260930)
- [x] Typst WASM frontend integration (browser mode fallback via @myriaddreamin/typst.ts)
- [x] Bi-directional vault sync (file watcher + write-back with checksum tracking)
- [x] Zotero integration (BibTeX/CSL-JSON import, collection-to-frame mapping (collection tags since 260930), direct library access)
In Progress
- [ ] Obsidian Plugin (sync x,y coordinates with Obsidian Canvas)
Future
- [ ] Obsidian Plugin — sync x,y coordinates between Nodus and Obsidian Canvas
- [ ] PDF import + highlights
- [ ] EU sync service (a CRDT layer for positions + hosting)
- [ ] Mobile PWA capture app
- [ ] User interviews with PhD students
Appendix: Key References
- Tauri v2: https://v2.tauri.app
- Typst: https://typst.app
- typst.ts: https://github.com/myriaddreamin/typst.ts (WASM)
- Yjs: https://yjs.dev (CRDT library considered for the planned sync layer)
- LibSQL: https://libsql.org (SQLite fork)
- D3-force: https://d3js.org/d3-force
- notify (Rust): https://docs.rs/notify (file watcher)
- Heptabase: UX reference
- Obsidian Canvas: JSON format for import compatibility
Document: /docs/PRODUCT_DESIGN.md
Version: 0.17.0 — Added: Frames documentation section with creation, interaction, and controls. Added Shift+F shortcut.