A battle-tested daemon that keeps a Notion database in two-way sync with a folder of local Markdown files, and auto-generates an Obsidian kanban board from the live state.
Built for people who want Notion as a task cockpit (mobile, sharing, rich views) while keeping a local Markdown view that plays nicely with editors like Obsidian, VS Code, or just grep.
Notion database ←→ ~/notes/todos/*.md ←→ kanban.md (Obsidian board)
All three directions are live:
- Notion → Local: new or changed Notion pages pull to
.mdfiles with YAML frontmatter within one poll interval (default 5 min, or immediately via HTTP trigger) - Local → Notion: edits to
.mdfiles (title, status, horizon, outcome, category, body) push to Notion within ~500 ms via a file watcher — no poll needed - Kanban → Notion: dragging a card to a different column in the Obsidian kanban plugin updates the Notion status within ~1 s, via a dedicated
kanban.mdwatcher - Local create: drop a new
.mdfile in the folder → a Notion page is created automatically - Instant Notion→Local: HTTP trigger endpoint + Cloudflare Tunnel lets webhook integrations (n8n, Zapier) force an immediate poll on any Notion change
- Dead-letter journal: failed syncs are tracked in a local SQLite database;
node deadletter.js --statusshows stuck files - Heartbeat alerts: if the daemon goes silent for 30+ minutes, a Telegram message is sent (credentials via macOS Keychain or env vars)
When both sides change between polls, the daemon compares timestamps:
- Local file mtime > Notion
last_edited_time→ local wins, push to Notion - Notion is newer → remote wins, pull overwrites local
Both cases are logged at warn level so you can audit them.
-
Empty-kanban guard — if
kanban.mdis parsed with zero active items but internal state has active items, the sync aborts. Fires when Obsidian writes a blank file or races with the daemon. -
Catastrophic-drop guard — if removing items from the kanban would affect more than 5 items and more than 50% of the active board in one pass, the drop is aborted. Status changes and new cards still go through.
-
Concurrency guard — a
pollInFlightflag prevents the scheduled poll and the HTTP trigger from running simultaneously. The second caller skips and logs. -
Poll timeout — if a poll hangs (e.g. on a stalled Notion API call),
pollInFlightis forcibly reset after 2 minutes so the daemon doesn't get permanently stuck. -
Mtime skip —
syncKanbanToNotioncheckskanban.mdmtime before parsing. If the file hasn't changed since the last run, the parse is skipped — so the every-5-min poll has no extra cost for the kanban path.
All guards log a SAFETY: warn entry so you know what happened.
Create a database with these properties (exact names matter):
| Property | Type |
|---|---|
Name |
Title |
Status |
Status (options: Backlog, In progress, Done) |
Horizon |
Select (options: Now, Later) |
Outcome |
Select (options: Completed, Dropped) |
Category |
Multi-select (any tags you want) |
The daemon validates the schema on startup and exits with a clear error if Status or Horizon are missing.
git clone https://github.com/waldo-van-der-code/notion-obsidian-sync.git
cd notion-obsidian-sync
npm install- Go to notion.so/my-integrations → New integration
- Copy the Internal Integration Token (
secret_...) - Open your database → ··· → Connections → add your integration
Config is resolved in priority order: env var → config.json → built-in default.
The simplest approach — set everything via env vars (also what the launchd plist uses):
export NOTION_TOKEN=secret_YOUR_TOKEN
export NOTION_DB_ID=YOUR_DATABASE_ID
export LOCAL_ROOT=/Users/you/notes/todos
export KANBAN_PATH=/Users/you/notes/kanban.mdOr copy config.example.json to config.json and fill in your values:
{
"dbId": "YOUR_DATABASE_ID",
"localRoot": "/Users/you/notes/todos",
"kanbanPath": "/Users/you/notes/kanban.md"
}The database ID is the 32-character hex string in the Notion page URL. To run with a non-default config file path: CONFIG_PATH=/path/to/my.json node index.js
See config.example.json for the full list of options.
# Normal mode (watches files + polls every 5 min)
npm start
# Dry-run (shows what would happen, no writes)
npm run dry-runmacOS: copy extras/com.yourname.notion-obsidian-sync.plist to ~/Library/LaunchAgents/, edit the paths, then:
launchctl load ~/Library/LaunchAgents/com.yourname.notion-obsidian-sync.plistLinux (systemd): copy extras/notion-obsidian-sync.service to ~/.config/systemd/user/, edit the paths, then:
systemctl --user enable --now notion-obsidian-syncEach synced item is a .md file with YAML frontmatter:
---
notion_id: abc123...
status: In progress
horizon: Now
outcome:
category: work, project-x
last_synced_at: 2026-04-30T12:00:00.000Z
last_modified_at: 2026-04-30T11:58:00.000Z
---
# My task title
Optional body content that syncs to the Notion page body.
<!-- local: notes below this line are not synced to Notion -->
Private notes here — only visible locally.| Field | Owner | Notes |
|---|---|---|
notion_id |
Notion | Set on first pull or push; never edit manually |
last_synced_at |
daemon | Updated on every pull; read-only |
last_modified_at |
daemon | Set from file mtime when a push succeeds; reflects when you last edited the file |
status |
local | Push wins on conflict |
horizon |
local | Push wins on conflict |
outcome |
local | Push wins on conflict |
category |
local | Comma-separated; must match validCategories if that list is non-empty |
| title (H1) | local | Push wins on conflict |
| body | local | Everything below the H1, above the <!-- local: --> marker |
| local notes | never synced | Everything below the <!-- local: --> marker |
chokidar watches LOCAL_ROOT. On any .md change, the daemon debounces 500 ms, parses the file, hashes the fields, and pushes only if the hash changed. A last_modified_at timestamp (from fs.stat mtime) is written back to the frontmatter on success so you can see when you last edited it.
Validation runs before every push. If a field value isn't in the allowed enum (e.g. an unknown category), the push is rejected and a <!-- sync-error: --> comment is injected at the bottom of the file. Fix the frontmatter and save to retry — the daemon will pick it up immediately.
A second chokidar watcher monitors KANBAN_PATH specifically. When Obsidian rewrites the file after a card drag, the watcher debounces 800 ms (after chokidar's own 500 ms write-finish stabilization) and calls syncKanbanToNotion. That function:
- Checks
mtime— skips entirely if the file hasn't changed since last run - Parses the column headings and card wikilinks
- Pushes any status changes to Notion (e.g.
Backlog → In progress) - Creates local files + Notion pages for new cards added directly in the kanban UI
- Marks items removed from active columns as
Done/Dropped(with catastrophic-drop guard)
The watcher is suppressed while the daemon is writing kanban.md itself (using a __kanban__ suppress token), so self-writes don't loop back.
The daemon queries the Notion DB for all in-scope items (default: Backlog + In progress; configurable via inScopeStatuses). For each item whose last_edited_time changed since the last sync, it fetches the page body and writes the local .md file. On conflict (both sides changed), the newer timestamp wins.
On startup, any file with sync_status: error in the state (e.g. a failed push from a previous run) is automatically retried before the first poll. This means transient errors (rate limits, network blips) self-heal on restart without manual intervention.
The daemon runs a local HTTP server on port 9876 (configurable via TRIGGER_PORT). Send a POST /trigger-poll to kick off an immediate poll without waiting for the interval:
curl -X POST http://127.0.0.1:9876/trigger-pollUse this to make Notion→Local sync effectively instant. The pattern that works:
- Expose the endpoint externally with Cloudflare Tunnel (or similar)
- Register a Notion webhook pointing at your n8n/Zapier/Make instance
- Have the automation POST to your tunnel URL on every Notion change
The daemon uses 127.0.0.1 (not localhost) — make sure your curl command and automation use the right host.
alias sync-now='curl -s -X POST http://127.0.0.1:9876/trigger-poll && echo "sync triggered"'| Env var | config.json key | Default | Description |
|---|---|---|---|
NOTION_TOKEN |
— | — | Notion integration token (required if tokenPath not set) |
NOTION_DB_ID |
dbId |
— | Notion database ID (required) |
NOTION_TOKEN_PATH |
tokenPath |
~/.config/notion/token |
Path to token file |
LOCAL_ROOT |
localRoot |
~/notion-todos |
Folder for local .md files |
ARCHIVE_DIR |
archiveDir |
<localRoot>/.archive |
Directory created on startup but not actively used — files stay in LOCAL_ROOT (see note below) |
KANBAN_PATH |
kanbanPath |
<localRoot>/../kanban.md |
Path for the generated kanban board |
STATE_PATH |
statePath |
~/.config/notion-obsidian-sync/state.json |
Sync state file |
LOG_PATH |
logPath |
~/.config/notion-obsidian-sync/sync.log |
Log file (JSON lines, 5 MB rotation) |
POLL_INTERVAL_MS |
pollIntervalMs |
300000 (5 min) |
How often to poll Notion |
TRIGGER_PORT |
triggerPort |
9876 |
Port for the HTTP trigger endpoint |
CONFIG_PATH |
— | ./config.json |
Path to config file |
TITLE_PROPERTY |
titleProperty |
Name |
Name of the title property in your Notion DB |
WIKILINK_PREFIX |
wikilinkPrefix |
notion-todos |
Vault-relative path prefix for wikilinks in kanban.md (e.g. notes/todos) |
BACKLOG_COLUMN_LABEL |
backlogColumnLabel |
📥 Backlog |
First column heading in the generated kanban board |
| — | kanbanSettings |
(see config.example.json) | Raw Obsidian kanban plugin settings JSON injected at the bottom of kanban.md |
ARCHIVE_DIRnote: completed files are not moved to.archive. They stay inLOCAL_ROOTso Obsidian can still resolve their wikilinks. The directory is created on startup for forward compatibility.
Singleton guard: the daemon uses a PID file (not a lockfile) to prevent duplicate instances. If the process holding the PID is dead, the stale file is cleaned up automatically on the next start.
Optional property flags (via config.json only — set false if your Notion DB omits that field):
| Key | Default | Notes |
|---|---|---|
hasHorizon |
true |
Set false if your DB has no Horizon property |
hasOutcome |
true |
Set false if your DB has no Outcome property |
hasCategory |
true |
Set false if your DB has no Category property |
Custom field enums (via config.json only):
| Key | Default | Notes |
|---|---|---|
validStatuses |
["Backlog", "In progress", "Done"] |
Must match your Notion Status options |
validHorizons |
["Now", "Later", ""] |
Must match your Horizon select options |
validOutcomes |
["Completed", "Dropped", ""] |
Must match your Outcome select options |
validCategories |
[] (any value accepted) |
If non-empty, pushes with unlisted categories are rejected |
inScopeStatuses |
all statuses except Done |
Which Notion statuses to include in the poll query |
Tip: If a push is rejected with
Invalid category "X", add"X"tovalidCategoriesinconfig.jsonand restart the daemon. The rejected file will be automatically retried on startup.
- Obsidian kanban plugin race: if the plugin rewrites
kanban.mdwhile the daemon is mid-poll, the empty-kanban guard will block any drops. The next poll (or a manualPOST /trigger-poll) resolves it cleanly. - kanban.md is both input and output: the daemon rebuilds it on every poll, but also watches it for changes. You can drag cards between columns using the Obsidian kanban plugin — that is a supported workflow. The
__kanban__suppress token prevents the daemon's own writes from triggering a sync loop. Do not hand-edit the raw Markdown inkanban.md; always use the Obsidian UI or edit the task.mdfile directly. - Body sync is best-effort: complex Notion blocks (databases, synced blocks, embeds) are flattened to markdown. Text, headings, bullets, and code blocks round-trip cleanly.
- Rename detection: if you rename a file in Obsidian (which creates a new file), the daemon detects the
notion_idmatch and updates state + pushes the new title to Notion. last_synced_atvslast_modified_at:last_synced_atis updated on every pull (daemon-owned).last_modified_atis set only when a push succeeds and reflects your last edit (fromfs.statmtime). Uselast_modified_atto see when you last touched the file.
You can run multiple instances of the daemon — one per Notion database — each with its own config, state file, lock file, and kanban board. This is useful when you want to keep a project-specific board separate from a personal todo board.
Instance 1 — personal todos (~/.config/notion/personal.json):
{
"dbId": "YOUR_PERSONAL_DB_ID",
"localRoot": "/Users/you/notes/todos",
"kanbanPath": "/Users/you/notes/kanban.md",
"statePath": "/Users/you/.config/notion/state-personal.json",
"lockPath": "/tmp/notion-sync-personal.lock"
}Instance 2 — project board (~/.config/notion/project.json):
{
"dbId": "YOUR_PROJECT_DB_ID",
"localRoot": "/Users/you/projects/myproject/todos",
"kanbanPath": "/Users/you/projects/myproject/kanban-project.md",
"statePath": "/Users/you/.config/notion/state-project.json",
"lockPath": "/tmp/notion-sync-project.lock",
"validStatuses": ["Not started", "In progress", "Done"],
"validCategories": []
}Run each with its own config path:
CONFIG_PATH=~/.config/notion/personal.json node index.js
CONFIG_PATH=~/.config/notion/project.json node index.jsOr register each as a separate launchd agent (macOS) by copying the plist template twice with different labels and env vars.
When syncing a project board, you can add custom fields to your .md files beyond the standard schema. Fields not in the Notion schema are ignored by the sync — they stay local-only (or you can add them as custom properties to your Notion DB).
Example WAG (Work Against Goals) ticket format for a project board:
---
notion_id: abc123...
status: Not started
epic: Epic 1 — Telegram Connection
assignee: henrik
---
# WAG-002: Set up Telegram bot
Tasks:
- Set up bot via BotFather
- Connect to Notion workspace
<!-- local: private notes below this line are not synced -->The recommended way to work with this daemon is local-first: write and edit .md files directly, let the daemon push to Notion, rather than editing Notion and waiting for a pull.
This reduces Notion API calls significantly:
| Pattern | API calls |
|---|---|
| Edit Notion, wait for pull (every 5 min) | 1 poll + 1 page fetch per change |
Edit local .md, push triggers immediately |
1 write per change, no polling needed |
| Drag card in Obsidian kanban | 1 status update, triggered within ~1 s |
For teams using Notion as a read/view layer (dashboards, mobile access, sharing) while doing all editing locally, the daemon can run with POLL_INTERVAL_MS=3600000 (hourly) — pulling Notion changes rarely — and rely on the file watcher for near-instant pushes.
MIT