- Python 95.5%
- Shell 4.5%
Same feedback as zettelmesh's own docs: too many terms assumed. Point at
the getting-started guide's new vocabulary table up front rather than
re-explaining, then fix the places a newcomer hits first.
- "Search is keyword-only out of the box ... fuses a dense (semantic)
search with a sparse (keyword) one; FTS5/BM25 ..." rewritten to say
what each kind of search can and cannot find, and what enabling the
second one actually requires (an external embedding service).
- dense search -> meaning-based search; "unambiguous ULID prefix" ->
"the first few characters of an id"; "grounded answer + citations" ->
"answer built only from your notes, with citations".
- Describe a workspace without leaning on "registry manifest",
"derived index" and "mesh" in the same sentence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UooXKWcwPqw5yAE7PzQ5Up
|
||
|---|---|---|
| config | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| README.md | ||
| setup.sh | ||
zettelmesh workspace
This is an example.
It is one worked configuration of
zettelmesh, wired to three specific repositories, kept as something you can clone and run in minutes to see what a working mesh looks like. It is not a component ofzettelmesh, not required to use it, and not a template you are meant to keep: the three repositories below are examples of the shapes (nativemode,adoptedmode, an adopted third-party repo), not content you need.Your own workspace will look like this structurally and share none of its content — see Making this your own below, and
docs/getting-started.mdin thezettelmeshrepo for the full setup guide.
A workspace is a directory that keeps one collection's moving parts
together: the file saying which git repos belong to the collection (the
registry), the checkouts of those repos, and the search index built
from them. zettel itself has no notion of a workspace — it reads three
environment variables — but the convention makes those obvious and lets
the registry live in git. This repository holds only that shared
configuration, plus docs and a build script; the knowledge bases are
separate repositories.
Unfamiliar terms below?
zettelmesh's getting-started guide opens with a short table explaining every one of them — frontmatter, id, backlink, adopted mode, embedding and the rest. This README assumes you have that, and concentrates on what these three specific repositories demonstrate.
Three repositories are wired in:
zettelmesh-handbook(mesh shortnamehandbook,mode: native) — documentation about the Zettelkasten method and aboutzettelmesh, written as a Zettelkasten followingzettelmesh's own conventions.zettelmesh/docs(mesh shortnamemesh-docs,mode: adopted) —zettelmesh's own design docs, adopted read-only as a live example of adopted mode.OWASP/CheatSheetSeries/cheatsheets(mesh shortnameowasp-cheatsheets,mode: adopted) — 120 application-security cheat sheets, adopted read-only over their public git URL (CC-BY-SA-4.0).
Many handbook notes carry cross-repo links — specified-in to their
counterpart in mesh-docs, and supports to the relevant
owasp-cheatsheets sheet — so zettel links and backlinks span all
three repositories.
Layout
zettelmesh-workspace/ (this repo)
├── README.md
├── setup.sh render config/registry.yaml + export env vars
├── config/
│ ├── registry.yaml.in the discovery manifest — which repos are in the mesh
│ ├── registry.yaml (git-ignored) rendered from .in by setup.sh (URLs filled)
│ └── taxonomy.yaml controlled tag vocabulary (format rule + word list)
├── scripts/
│ ├── build_kb.py regenerates the handbook's notes from their specs
│ └── fetch-owasp-cheatsheets.sh blobless pre-seed of the OWASP checkout
├── kaesten/ (git-ignored) local checkouts of KB repos
├── clones/ (git-ignored) zettel sync clones repos here
└── .index/ (git-ignored) the SQLite search index
clones/ and .index/ are pure derivations: delete them and
zettel sync rebuilds an identical result. Every repo in this
workspace is read-only to zettel (handbook is read_only: true,
the other two are mode: adopted), so nothing is ever authored in
clones/ here.
kaesten/ is only a convenient place to keep hand-managed checkouts —
scripts/build_kb.py regenerates the handbook's notes into
kaesten/handbook and you commit them there with plain git. That is a
git workflow, not a zettel one: zettel new / edit / commit
always operate on $ZETTEL_ROOT/<shortname>, never on kaesten/. In
your own workspace you would simply author in clones/ — see
Making this your own.
Installing the zettel CLI
zettel is not on PyPI — it is the zettelmesh repository
([project.scripts] zettel = "zettel.cli.main:app"), so it installs
from a git URL with uv, which also
fetches a suitable Python (requires-python = ">=3.12"):
uv tool install "zettel[openai,tui] @ git+ssh://git@code.hans-aschauer.de:222/hans/zettelmesh.git"
uv tool update-shell # once — puts ~/.local/bin on $PATH
[openai] adds meaning-based search and zettel ask/chat; [tui] adds
zettel tui; [mkdocs] adds the static-site plugin. Prerequisites per
platform, the developer (editable-checkout) install, and the uvx /
uv run variants are all in
docs/getting-started.md.
The examples below assume zettel is on your PATH.
Quickstart
Point zettel at this workspace and sync:
cd zettelmesh-workspace
source ./setup.sh # renders config/registry.yaml from .in and exports:
# ZETTEL_REGISTRY_PATH, ZETTEL_ROOT, ZETTEL_INDEX_DB
./scripts/fetch-owasp-cheatsheets.sh # once: ~50 MB blobless checkout of the
# OWASP repo into clones/ (its full history is
# ~2.4 GB — this keeps `zettel sync` to a pull)
zettel init # create ~/.config/zettel (no registry is fetched)
zettel sync # clone every repo in the manifest you can reach, index it
# -> "reindexed: handbook"
# "reindexed: mesh-docs"
# "reindexed: owasp-cheatsheets"
zettel search "reciprocal rank fusion"
# 1. 16. Phase 13 — Hybrid Search / RRF
# mesh-docs · 2026-08-21 · zettelmesh
# mesh-docs:077CFA50X4PJPCTFD168NP36PN
# 2. Hybrid search: dense plus sparse [concept]
# handbook · 2026-07-15 · zettelmesh-docs
# handbook:01KXHH5F00A043HS2ZY2Q8GFMG
# ( zettel search … --json for a machine-readable array )
zettel links 01KWJMCV # the first few characters of an id are enough, if unambiguous
# -> [elaborates] … What zettelmesh is
# -> [applies] … A note's identity is fixed at creation
# -> [specified-in] mesh-docs:0DE01E1C… 5. Phase 2 — Note Identity
zettel show 01KT2T2J --range 0:200 # prefix works here too
zettel lint handbook # checks tags against config/taxonomy.yaml
Work offline / on the checkouts directly: point the registry at local clones instead of the SSH URLs —
git clone <handbook-url> kaesten/handbook
git clone <zettelmesh-url> kaesten/zettelmesh
ZETTEL_KB_URL="$PWD/kaesten/handbook" \
ZETTEL_MESH_URL="$PWD/kaesten/zettelmesh" \
source ./setup.sh && zettel sync
Out of the box, search matches words. zettel search finds notes
that literally contain what you typed. That needs no setup and is
usually enough to find a way in — from there you follow links.
Optionally it can also find notes that are about your query even when
they share none of its words ("meaning-based", or semantic, search).
That needs the openai extra and an account with a service that turns
text into embeddings — any endpoint speaking the OpenAI /embeddings
format, including one you run yourself. Copy
.env.example into place and fill it in:
cp .env.example ~/.config/zettel/.env # then set ZETTEL_EMBEDDING_MODEL / _API_KEY
zettel embed handbook # send the notes off to be embedded, store the results
zettel sync # load the results into the index
zettel search "..." # now matches by words AND by meaning
Full explanation of what this does, what it costs and what leaves your
machine: docs/getting-started.md.
Where .env goes — exactly two places, first match wins; real
environment variables beat both:
$ZETTEL_ENV_FILE— a file you name explicitly. Use this to keep the key next to a project (e.g.export ZETTEL_ENV_FILE="$PWD/.env";.envis git-ignored here).<config_dir>/.env—~/.config/zettel/.envon Linux,~/Library/Application Support/zettel/.envon macOS,%LOCALAPPDATA%\zettel\zettel\.envon Windows, or$ZETTEL_CONFIG_DIR/.env. Works from anywhere — recommended for a machine-wide key.
zetteldeliberately does not search the current directory or walk up from it looking for.env. That is a security decision (SECURITY-REVIEW-2026-09.mdF4): runningzettel embedorzettel askwith your shell inside an untrusted checkout must not let that repo's committed.envredirect your embedding traffic or inject an API key. If you want a workspace-local.env, point$ZETTEL_ENV_FILEat it.
.env.example lists every knob; docs/extensions/openai-embeddings.md
in the zettelmesh repo explains them.
Asking questions, browsing, editing
The same install covers the rest of the toolchain; each is set up in
docs/getting-started.md:
zettel ask "how do permissions work?" # answer built only from your notes, with citations
zettel chat # the same, as a REPL
zettel tui # full-screen terminal browser ([tui] extra)
zettel serve --lsp # link completion/hover/backlinks in any LSP editor
For VS Code there is a
separate extension
that runs both servers and derives every path from one
zettelmesh.workspacePath setting — point it at this directory. To
browse the mesh as a website, the [mkdocs] extra registers a plugin
that renders this index as a static site.
Connecting an agent (MCP)
zettel serve --mcp exposes the search / get_note / follow_links /
filter_by_metadata operations to an AI agent over
MCP. It runs as you, with your git
view of the mesh — there is no service account.
# same machine — the agent spawns it over stdio (Claude Code):
claude mcp add zettel \
--env ZETTEL_REGISTRY_PATH="$PWD/config/registry.yaml" \
--env ZETTEL_INDEX_DB="$PWD/.index/index.db" \
-- zettel serve --mcp
# agent elsewhere (container / another host) — bind a socket:
zettel serve --mcp --transport http --host 0.0.0.0 --port 8765
# then, in the container:
# claude mcp add --transport http zettel http://HOST:8765/mcp
Anything that can reach that port gets your full read access, so only
bind a non-loopback --host on a trusted network. Full transport
matrix and other clients: docs/extensions/mcp-server.md in the
zettelmesh repo, and the How to use the MCP server with an agent
handbook note.
The reference knowledge base
zettelmesh-handbook is a native-mode repository: 46 markdown notes in
notes/ named YYYY-MM-DD_slug.md, each with zettelmesh-canonical
frontmatter, an immutable handbook:<ULID> id, a type:, kebab-case
tags: from config/taxonomy.yaml, and typed links: (…#elaborates,
…#supports, …#applies, …#contrasts) — same-repo and cross-repo,
into mesh-docs and owasp-cheatsheets. There is deliberately no index
note. Four topics:
topic |
what it covers |
|---|---|
zettelkasten-method |
the method: atomic notes, permanent ids, links over hierarchy, emergence |
zettelmesh-model |
how the tool works: identity, git as the ACL, the registry, repo modes, hybrid search, sidecars, chunking, backlinks, typing, linting |
zettelmesh-rationale |
bridges: each note shows a tool decision following from a method rule |
zettelmesh-practice |
how-tos: install the CLI, point zettel at a registry, create a note, sync/search, enable dense search, adopt a repo, use the MCP server |
scripts/build_kb.py wipes notes/ and regenerates every note from its
spec (deterministic ULIDs — for handbook notes from the slug, for the
cross-repo link targets from (<adopted-shortname>, path), the same
formula zettelmesh itself uses — so identity is stable across
rebuilds):
git clone <handbook-url> kaesten/handbook
uv run --project ../zettelmesh python scripts/build_kb.py # writes kaesten/handbook/notes/
cd kaesten/handbook && git add -A && git commit
The adopted repositories: mesh-docs and owasp-cheatsheets
The mesh-docs entry adopts docs/**/*.md from the zettelmesh repo
read-only. It shows what mode: adopted gives you:
- Nothing is written to the source. The docs get no
id:field. - Identity from
(repo, path), derived deterministically, stored in a local table so a rebuilt index yields the same ids and renames are followed viagit log --follow. - A
frontmatter_adaptersuppliesauthor/department/topic/tagsasdefaults, takesdatefrom the last git commit, and — since the docs have no frontmattertitle— takes each note's title from its first#heading (title_from_first_heading). - Embeddings via the shared cache: adopted repos can't carry sidecar
files, so their dense vectors live in the registry's
cache_urlrepo (clones/_embedding-cache), filled byzettel embed <shortname>. Until a repo has entries there it is keyword-searchable only.
zettel lint mesh-docs is clean; zettel search and zettel links
treat mesh-docs notes exactly like native ones.
owasp-cheatsheets is the same story against a third-party repo pulled
over its public GitHub URL: it adopts cheatsheets/*.md (the 120
published cheat sheets — drafts and the Index*.md navigation files are
left out by the glob), takes each title from the file's leading #
heading, and dates each note from its last git commit. Every note gets
the same defaults — author: owasp, topic: application-security,
tags: [security, owasp, appsec] — since adopted mode cannot derive
per-note metadata. Dense search for these notes comes from the shared
cache_url repo: run zettel embed owasp-cheatsheets (with an
embedding provider configured), then commit and push
clones/_embedding-cache. Until that cache has entries, the OWASP notes
are keyword-searchable only.
Adding another knowledge base
-
Create its own git repository with a
notes/directory and at least one committed note. -
Add an entry to
config/registry.yaml.in, then re-runsource ./setup.sh:repos: - shortname: <name> url: <git URL or local path> mode: native # or colocated / adopted topic: <topic> department: [<dept>] -
zettel sync. Links across the two repos now resolve as<name>:<ULID>; a link into a repo you cannot read shows as a reference without leaking its content.
For a read-only third-party repo use mode: adopted (+ paths:, an
optional frontmatter_adapter, and acl_confirmed: true). See the
How to adopt an existing repository note in the handbook.
Making this your own
Nothing here is meant to be kept. Your own Zettelkasten is two git repositories, and you can start with just the first.
1. A notes repo — this is your Zettelkasten. An ordinary git repo
whose only requirement is a notes/ directory:
mkdir my-kasten && cd my-kasten
git init && mkdir notes
git commit --allow-empty -m "empty kasten"
git remote add origin <your-git-server>/my-kasten.git && git push -u origin main
No config file, no manifest, no schema to declare. zettel new writes
correctly-shaped notes into notes/; zettel lint checks hand-written
ones.
2. A workspace repo — the same shape as this one, with your repos.
mkdir ~/my-workspace && cd ~/my-workspace
git init && mkdir -p config clones .index
printf 'clones/\n.index/\n.env\n' > .gitignore
cat > config/registry.yaml <<'YAML'
repos:
- shortname: kasten # lowercase letter, then letters/digits/hyphens
url: <your-git-server>/my-kasten.git
mode: native
topic: personal
department: [me]
YAML
cat > setup.sh <<'SH'
#!/usr/bin/env bash
# source ./setup.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export ZETTEL_REGISTRY_PATH="$ROOT/config/registry.yaml"
export ZETTEL_ROOT="$ROOT/clones"
export ZETTEL_INDEX_DB="$ROOT/.index/zettel.db"
SH
git add -A && git commit -m "workspace: my kasten"
source ./setup.sh && zettel init && zettel sync
You do not need the registry.yaml.in / setup.sh templating this
repo uses — that exists only so the example's URLs can be swapped for
local checkouts to work offline. A plain committed registry.yaml is
simpler and fine.
shortname is the stable identifier baked into every note id
(kasten:01J…) and every cross-repo link, deliberately not the git
URL so the repo can move or be renamed. Choose it once — changing
it later invalidates every link that names it.
Then write. zettel new writes into the checkout zettel sync
maintains ($ZETTEL_ROOT/kasten) — you do not clone a second copy —
and --push is not optional: an unpushed local commit makes the
next zettel sync report the repo diverged and skip it, so the note
never reaches the index.
zettel new kasten "My first note" --author me --department me --topic personal --tags concept
zettel edit <the-id-it-printed> # opens $EDITOR
zettel commit kasten --push
zettel sync
zettel list
A controlled tag vocabulary (config/taxonomy.yaml, referenced from
the registry as tags_taxonomy: and resolved relative to
registry.yaml) is worth adding once more than one person writes
notes — copy config/taxonomy.yaml as a
starting point.
The full walkthrough — prerequisites per platform, the developer
install, embeddings, chat, TUI, LSP, VS Code, MCP and the MkDocs site,
plus a complete config and environment reference — is in
docs/getting-started.md.