Zettel is a local-first, privacy-preserving note-taking app built around a simple idea: capture first, think later. Hit a global hotkey, dump a thought, and get on with your day — nothing is analyzed yet. When you're ready, you review the capture in your Inbox, give it an optional category, and send it on. Only then does a local extraction pipeline read it with a local small language model, pull out entities, relationships and action items, weave them into a personal knowledge graph, and — once a cluster of related ideas gets dense enough to mean something — synthesize a Master Note out of it.
Everything runs on your machine. No capture, embedding, or extracted entity ever leaves it.
⌘⇧K → SQLite (Inbox) → garden → poller → Extractor → Graph Updater → Task Manager → Synthesizer
│ │ │
Kùzu + LanceDB tasks table Master Notes
- Getting Started / Installation
- Usage
- Tech Stack
- Architecture
- Design Decisions Worth Knowing
- Data & Configuration
- Development
Requirements: Ollama running locally, Python 3.13, Node 22+, pnpm, and Rust (for the Tauri shell).
git clone https://github.com/cubeerea/zettel-ai.git
cd zettel-ai
make setup # creates the Python venv, installs backend + desktop deps
make models # ollama pull qwen2.5-coder:7b, nomic-embed-text
make app # launches the desktop app, which starts the backend itselfmake app is the one command you need day to day — the desktop shell spawns and
supervises the Python backend automatically. See Development below if
you want to run pieces separately (e.g. the backend alone, for debugging).
Press ⌘⇧K anywhere to capture. The overlay commits your text and disappears — that part is instant, no model call is on that path. The capture then waits in the Inbox until you garden it; extraction only starts once you send it on, and never makes you wait when it does.
The app is organized into a few views, reachable from the sidebar:
- Inbox — every capture waiting to be gardened. Edit the text, give it an optional category, and send it to curation; nothing here has reached the engine yet.
- Stream — every raw capture, newest first, gardened or not.
- Search — hybrid semantic + keyword search, with a category filter to browse straight to everything you've tagged a given way.
- Action Board — extracted tasks in swimlanes by inferred project. Click a card to advance its status.
- Graph — a force-directed concept map, colorable by entity type or by category. Click a node to see it beside the raw notes that produced it, or jump here directly from a note's entity chips.
- Master Notes — synthesis on the left, its exact source captures on the right. Edit the left pane and save to trigger re-curation. You can also manually attach any existing capture to a Master Note's source list — a deliberate override of the automatic clustering that never rewrites the prose.
- Duplicates — captures the engine flagged as likely duplicates, for you to confirm or dismiss.
| Layer | Technology | Role |
|---|---|---|
| Desktop shell | Tauri v2 (Rust) | Global hotkey, capture overlay, supervises the backend process |
| UI | React 19 + TypeScript + Tailwind v4 | Inbox, Stream, Action Board, Graph Canvas, Master Notes |
| Data fetching | TanStack React Query | Polling, caching, optimistic updates |
| API + engine | FastAPI (Python) | Async extraction pipeline over gardened, unprocessed notes |
| Relational store | SQLite | Immutable raw captures, tasks, master notes, provenance |
| Graph store | Kùzu | Entity nodes, RelatesTo edges |
| Vector store | LanceDB | Semantic search over captures (nomic-embed-text, 768-dim) |
| Models | Ollama | qwen2.5-coder:7b for extraction and synthesis |
| Graph rendering | react-force-graph-2d | Force-directed concept map |
A capture is not eligible for the ingest poller the instant it lands — it sits in the Inbox until you review it. Gardening a note (optionally giving it a category) is what sends it on; the category rides along as a hint into the extractor prompt, so the engine groups text into the right entities and Master Notes instead of guessing from an unedited brain dump. It's also indexed for search. Once a note has been gardened it stays gardened: editing or reprocessing it later never sends it back to the Inbox.
- Extractor — grammar-constrained JSON out of the SLM (Ollama's
formattakes a real JSON Schema), plus an embedding and the gardening category as a hint. Falls back through a JSON-repair ladder rather than dropping a note. - Graph Updater — MERGEs entities and relationships into Kùzu, upserts the
embedding into LanceDB, and records
entity_mentions— the row every provenance query depends on. - Task Manager — writes genuine action items to
tasks, inferring the project. - Synthesizer — communities over the entity graph (Louvain); those that clear the density gate become Master Notes. Manual and cadence-triggered synthesis passes are serialized against each other so they can't race.
- Re-Curation — when you edit a Master Note, re-parses your text and folds your corrections back into the graph.
The pipeline itself is a small set of plain async Python functions chained together — no external orchestration framework, just the node sequence above run by an async poller.
Kùzu allows one read-write handle per process. The API server and the ingest
poller therefore share a single process, and uvicorn must run with workers=1 and
without --reload (which forks). This is enforced by convention, not by the library —
see the comment at the top of backend/zettel/main.py.
Capture does one INSERT and returns. No model call is on that path. That is the entire reason the hotkey feels instant.
Gardening is mandatory, not a nicety. The poller will not claim a note until it has been gardened, full stop — there is no setting to skip the Inbox. This mirrors how people actually brain-dump (get it down fast, make sense of it later) and gives the engine a human-confirmed category instead of only ever guessing from raw, unedited text.
Community detection runs on explicit relations unioned with co-mention.
Extractors overwhelmingly emit hub-and-spoke relations, and a star is a tree — its
density is exactly 2/n, which can never clear a supra-tree gate. Detection on
explicit edges alone would produce communities that never qualify, and no Master Note
would ever be written. Co-mention (the standard bipartite projection of note→entity)
restores the density that actually reflects "these ideas belong together." The
rendered graph still shows only explicit relations.
A Master Note you have edited is never regenerated. is_user_edited permanently
protects your prose; synthesis refreshes only its provenance links.
Master Note identity survives re-clustering. Louvain re-partitions the whole graph each pass, so notes are matched by entity-set Jaccard and containment. Containment matters: when a community splits, Jaccard collapses and the fragment would otherwise be minted as a near-duplicate over the same sources.
A weak extraction never wedges the queue. failed is reserved for infrastructure
faults; a note the model merely handled badly is still marked completed and still
gets its embedding.
Duplicate detection is a two-stage funnel. A cheap cosine-similarity recall pass over embeddings is deliberately over-inclusive (tuned for recall, not precision); a second pass asks the SLM to actually adjudicate each candidate before anything is flagged for review or merged.
Everything lives in ~/.zettel (override with ZETTEL_DATA_DIR):
~/.zettel/
├── zettel.db # raw_notes, tasks, master_notes, provenance
├── graph/ # Kùzu
└── vectors/ # LanceDB
Configuration is environment-driven — see backend/zettel/config.py for every knob
(models, ports, synthesis cadence, density thresholds, dedup thresholds).
make test # backend test suite, no model required
make smoke # live extraction over the PRD sample inputs
make backend # run the backend alone with readable tracebacks
make e2e # push the samples through a running backend and print the resultsIn dev the Rust shell starts the backend straight from backend/.venv. In a packaged
build it runs a PyInstaller sidecar:
make bundle # builds the sidecar, then the .appThe child process is killed on exit — an orphaned backend keeps the Kùzu write lock,
and the next launch would fail to open its own database. If you ever see the backend
fail to start while the app still runs fine, it's almost always a leftover make backend process holding that lock; kill it and relaunch.