No description
  • Python 95.5%
  • Shell 4.5%
Find a file
hans aschauer dfd6541784 docs: plain-language pass on the README
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
2026-09-10 23:02:20 +00:00
config taxonomy: add 'publishing' tag (used by the new website howto) 2026-09-06 18:19:44 +00:00
scripts build_kb: website howto uses --with-executables-from mkdocs (uv tool install links only the named package's scripts) 2026-09-06 18:31:29 +00:00
.env.example docs: frame this repo as an example, fix stale .env and authoring docs 2026-09-10 05:26:41 +00:00
.gitignore registry.yaml: template + git-ignore the generated file 2026-08-31 09:04:19 +00:00
README.md docs: plain-language pass on the README 2026-09-10 23:02:20 +00:00
setup.sh config: cache_url terminology (registry key + setup.sh var) 2026-09-06 08:40:08 +00:00

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 of zettelmesh, not required to use it, and not a template you are meant to keep: the three repositories below are examples of the shapes (native mode, adopted mode, 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.md in the zettelmesh repo 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 shortname handbook, mode: native) — documentation about the Zettelkasten method and about zettelmesh, written as a Zettelkasten following zettelmesh's own conventions.
  • zettelmesh/docs (mesh shortname mesh-docs, mode: adopted) — zettelmesh's own design docs, adopted read-only as a live example of adopted mode.
  • OWASP/CheatSheetSeries/cheatsheets (mesh shortname owasp-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:

  1. $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"; .env is git-ignored here).
  2. <config_dir>/.env — ~/.config/zettel/.env on Linux, ~/Library/Application Support/zettel/.env on macOS, %LOCALAPPDATA%\zettel\zettel\.env on Windows, or $ZETTEL_CONFIG_DIR/.env. Works from anywhere — recommended for a machine-wide key.

zettel deliberately does not search the current directory or walk up from it looking for .env. That is a security decision (SECURITY-REVIEW-2026-09.md F4): running zettel embed or zettel ask with your shell inside an untrusted checkout must not let that repo's committed .env redirect your embedding traffic or inject an API key. If you want a workspace-local .env, point $ZETTEL_ENV_FILE at 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 via git log --follow.
  • A frontmatter_adapter supplies author / department / topic / tags as defaults, takes date from the last git commit, and — since the docs have no frontmatter title — 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_url repo (clones/_embedding-cache), filled by zettel 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

  1. Create its own git repository with a notes/ directory and at least one committed note.

  2. Add an entry to config/registry.yaml.in, then re-run source ./setup.sh:

    repos:
      - shortname: <name>
        url: <git URL or local path>
        mode: native            # or colocated / adopted
        topic: <topic>
        department: [<dept>]
    
  3. 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.