I've used org-roam for a while now for my personal Zettelkasten. This started with me leaving Evernote for Notion. Notion was a good tool, but it's still not a tool that I control/manage. If Notion goes out of business or changes its model in 10 years, I don't want to have to redo my system again.

So, I've been using org-roam.

emacs setup & functionality

My settings.org setup is like this:

; needed for =org-roam-deadgrep=
(use-package deadgrep)

; These are outside use-package so they can be overridden
(setopt org-roam-directory (expand-file-name "FIXME-PATH-TO-ORG-ROAM"))
(setopt org-roam-capture-templates
          '(("d" "default" plain "%?" :target
	    (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "#+title: ${title}

- tags :: ")
	    :unnarrowed t)))

 (use-package org-roam
    :after org
    :commands org-roam-db-query
    :custom
    (org-roam-mode-sections
	  (list #'org-roam-backlinks-section
		#'org-roam-reflinks-section
		;; #'org-roam-unlinked-references-section
		))

    (org-roam-node-display-template
        (concat "${title:*} "
                (propertize "${tags:10}" 'face 'org-tag)))
    :config
    (org-roam-setup)
    (defun org-roam-node-insert-immediate (arg &rest args)
      (interactive "P")
      (let ((args (cons arg args))
            (org-roam-capture-templates (list (append (car org-roam-capture-templates)
                '(:immediate-finish t)))))
    (apply #'org-roam-node-insert args)))

    (defun org-roam-deadgrep (arg)
      (interactive "MSearch term: ")
      (deadgrep arg org-roam-directory))

    ; see https://gist.github.com/borwickatuw/098b0d3a3b599f3f00c069309e8fd462
    (require 'org-roam-copy)
    ; https://github.com/org-roam/org-roam/issues/991#issuecomment-882010053
    (add-to-list 'magit-section-initial-visibility-alist (cons 'org-roam-node-section 'hide))

    :bind (("C-c n f" . org-roam-node-find)
           ("C-c n r" . org-roam-node-random)		    
           ("C-c n c" . org-roam-capture)
           ("C-c n g" . org-roam-graph)
	   ("C-c n s" . org-roam-deadgrep)
	   ("C-c n y" . org-roam-copy-node)
           (:map org-mode-map
                 (

		  ("C-c n i" . org-roam-node-insert)
		  ("C-c n I" . org-roam-node-insert-immediate)
                  ("C-c n o" . org-id-get-create)
                  ("C-c n t" . org-roam-tag-add)
                  ("C-c n a" . org-roam-alias-add)
		  ("C-c n l" . org-roam-buffer-toggle)	   
		  ))))

In practice, here's what all that does:

  • C-c n is the org-roam prefix
  • C-c n f finds a node
  • When in org-roam, C-c n i inserts a reference
  • Searching will show the tags for the node
  • John B. addition: C-c n s does a "deadgrep" search through org-roam content
  • John B. addition: C-c n y will call org-roam-copy. This will find the node and then go to the :COPY: property and copy its content. For example, if you have a node My work phone number and you put your number in the :COPY: property then when you use C-c n y and find the node, your clipboard will get the phone number. Basically this is a quick copy&paste for non-sensitive content

Adding more data into org-roam

Recently, I've been adding more data into org-roam:

contacts

This one is actually a few years old. I use vdirsyncer to sync my Google contacts to my local computer. I then have a one-way sync from vdirsyncer into org-roam. This lets me pull contact details into org-roam. I can then annotate the entries, e.g. to make a note when I last called a vendor.

emails

This one is way more experimental; I had Claude Code create some scripts that in turn called ollama to parse some of my old emails and identify other contacts I needed to add.

mnemosyne

I have a life archive where I keep copies of stuff. I created a job that will generate an org-roam file mnemosyne for this archive, including the top- and second-level directory structures. Each top-level directory gets its own org-roam ID/node.

notion

This is a one-time import from notion. It is pretty messy because I wanted to select exactly what content to migrate. For example, I wanted some of the Notion databases to come across but as a single org-roam page.

pinboard

This is an ongoing sync job that creates a "pinboard" org-roam entry. That entry has all my pinboard bookmarks in it along with their tags. This content is marked read-only in org-roam. Each item is also an org-roam node. This way I can reference content without needing to pull a whole copy of the content into org-roam. This also reinforces that pinboard is the "correct" location for external bookmarks.

p.s. this is also insurance for if pinboard.in goes away one day.

wiki

This makes a read-only copy of all the org files that generate https://johnborwick.com/wiki/. I want to be able to reference these in org-roam.

youtube

I list playlists in a config file and then this generates a read-only org-roam file for each playlist. This also has code to look up private video titles (for videos that have been made private since I added them to a playlist, such as a version of David Bowie's "Life on Mars").

org-roam auditing

Turn out that org-roam content can really go stale/bad, especially when you're dumping a ton of content into it! I have several jobs to help with this:

  • audit.py: Fixes structural issues such as sub-headings without parent headings
  • broken_refs.py: Finds references to files or org-roam IDs where the target doesn't exist. Also looks for stray [[ characters
  • orphan_media.py: Finds media that isn't linked anywhere
  • web_content.py: Finds org-roam content that's really copied from a web site. (My Notion import, especially, had a lot of copied HTML content from web sites.) I can then interactively remove this content & add it to pinboard.

Using org-roam on my phone

So, org-roam doesn't work great on the phone. There are org-mode readers but they don't respect the org-roam id: links. So, last year I built a basic exporter. It collates the org-roam files into one gigantic org file, converts the ID links into anchors, and then runs pandoc on them. I then put the file in Dropbox.

Unfortunately, I now have an iPhone and the iPhone doesn't want Safari to open a local file. So, I have recently switched to generating an .epub file. I can then open this on the phone.

Additionally, I added a git post-commit hook to my org-roam directory, so that this epub file gets regenerated. Here's the hook:

#!/bin/bash
set -e

PLIST=~/Library/LaunchAgents/com.johnborwick.org-roam-export.plist
PLIST_SOURCE=FIXME-PATH-TO/com.johnborwick.org-roam-export.plist
LABEL=com.johnborwick.org-roam-export

if ! launchctl list "$LABEL" >/dev/null 2>&1; then
    if [ ! -f "$PLIST" ]; then
        test -f "$PLIST_SOURCE" || { echo "ERROR: $PLIST_SOURCE not found" >&2; exit 1; }
        cp "$PLIST_SOURCE" "$PLIST"
    fi
    launchctl load "$PLIST"
fi

touch ~/.local/share/org-roam-export-trigger

This bootstraps a launchctl job that watches that org-roam-export-trigger file. When that file changes, it kicks off the job. This is necessary because I have a hook in Emacs to commit to git when I save an org-roam file. Emacs will wait on the hook to finish running, even if you use nohup and stuff.

Here's the plist file:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.johnborwick.org-roam-export</string>
    <key>ProgramArguments</key>
    <array>
        <string>FIXME-PATH-TO/run_org_roam_export.sh</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/john/.local/share/org-roam-export-trigger</string>
    </array>
    <key>StandardOutPath</key>
    <string>/tmp/org-roam-export.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/org-roam-export.log</string>
</dict>
</plist>

In turn, the run_org_roam_export.sh script runs my org_roam_export.hy script.

Leveled up!

With all of this, I feel like I really have a clear, connected system now:

  • Readwise Reader is for reading non-books (I have separate jobs that will synchronize Readwise Reader with pinboard unread bookmarks)
  • A book tool (currently Hardcover) tracks the books I want to read
  • org-roam has all my reference content
  • I can link to org-roam from elsewhere in Emacs
  • I can access the org-roam content via iOS's Books app (sort of)

Updated: