My sync daemon wiped 28 tasks in two seconds.

One morning I opened Obsidian and my kanban board was empty. The files were still on disk, but in Notion 28 tasks, some of them active for months, had been marked Done or Dropped. My sync daemon had done it in under two seconds, and it was working exactly as written.

I built the daemon because Notion is great and also exhausting. Every click goes through a server, and opening it to check a task feels like loading a dashboard when all I wanted was a sticky note. So I built a local-first alternative: Obsidian for the kanban board, and a Node.js daemon that syncs it with Notion in the background. It worked well for months.

How the sync works

The daemon does three things. It polls Notion every 5 minutes for changes. It watches the local folder with chokidar, so saving any .md file pushes the change to Notion within 500 ms. After every sync it rebuilds kanban.md, a static file that Obsidian renders as a kanban board.

Each task is one Markdown file, with YAML frontmatter that mirrors the properties of its Notion page:

---
notion_id: abc123...
status: In progress
horizon: Now
outcome:
category: work, project-x
last_synced_at: 2026-04-30T12:00:00.000Z
---

# Task title

Optional body that syncs to the Notion page body.

<!-- local: notes below this line are not synced to Notion -->
Private notes. Only visible locally.

Somewhere in those months I stopped thinking of the daemon as software I was building and started treating it as infrastructure I relied on. That was the mistake.


What broke

Finding the root cause took about an hour. When Obsidian loads a kanban file, the kanban plugin re-serializes the board state, and during a startup race it wrote a partially initialized kanban.md with zero active cards. The daemon was watching that file for status changes. It parsed the rewritten board, found no active items and concluded that every active task had been removed from the board. It marked all of them Done or Dropped in Notion and archived the local files, and the whole thing took about two seconds.

The daemon wrote kanban.md and also read it back as the truth. When Obsidian saved the board with 0 cards at startup, the daemon read the empty board and closed 28 tasks in Notion in about two seconds.Daemon writes boardkanban.mdObsidian saves 0 cardsDaemon reads boardNotion: 28 tasks closedread as inputnext syncin about two secondsStacked: the daemon writes kanban.md, Obsidian saves it with 0 cards at startup, the daemon reads the empty board as input and closes 28 tasks in Notion in about two seconds. On the next sync it writes the board again.Daemonwrites boardObsidian saves0 cardskanban.mdDaemon reads boardNotion: 28 tasks closedread as inputabout two secondsnext sync
kanban.md was the daemon's output and its input. One empty save by the Obsidian kanban plugin was enough to mark every active task Done or Dropped.

Three things had to go wrong at once:

  1. The board was output and input at the same time. The daemon generated kanban.md and also read it to learn which tasks were active. A generated file should never be treated as an authoritative input.
  2. Nothing limited the blast radius. The daemon would apply a drop to every single active task in one pass.
  3. Nothing checked for an empty file. A board with zero active cards looked exactly like “the user deleted all tasks”.

The fixes

The first three fixes decide what a change on the board is allowed to do. The state file now decides which tasks exist, and two guards check every pass before a drop reaches Notion. A pass that trips a guard drops nothing and waits for the next poll.

Now a board change is first compared with the state file, then checked by two safety guards. A normal pass syncs to Notion; an empty board or a mass drop skips the drops, logs a warning and tries again on the next poll.Board changesCompare with stateTwo safety guardsSkip drops, log warningSync to Notionlooks normalempty or mass dropnext pollStacked: a board change is compared with the state file and checked by two safety guards. A normal pass syncs to Notion; an empty board or a mass drop skips the drops, logs a warning and tries again on the next poll.Board changesCompare with stateTwo safety guardsSync to NotionSkip drops, log warninglooks normalempty or mass dropnext poll
The state file decides which tasks exist; the board only reports moves. When a guard trips, no task is dropped in Notion and the next poll tries again.

1. The board becomes a view

The kanban board is generated output, and the daemon now treats it that way. The JSON state file decides which tasks exist, and the board is read only to detect cards moving between columns. A corrupted board can no longer testify about what is real. A comment at the top of kanban.md marks it as rebuilt automatically:

<!-- AUTO-GENERATED — do not edit. To change a task status, edit its status: field in todos/<filename>.md -->

Moving cards still works. Dragging a card to another column in Obsidian syncs to Notion within about a second, because a file watcher on kanban.md starts the sync immediately instead of waiting for the next poll.

2. The empty-board guard

Before it processes any drops, the daemon now compares its own state with what the board claims:

if (kanbanFilenames.size === 0 && activeInState > 0) {
  log.warn('SAFETY: kanban has 0 active items but state has active items — skipping sync', {
    activeInState,
  });
  return;
}

If the board looks empty while the state still knows about active tasks, the sync pass aborts and waits for the next poll.

3. The mass-drop guard

A board that is not empty can still be wrong, so a large batch of drops in one pass is treated as suspicious:

if (dropActions.length > 5 && dropActions.length > kanbanFilenames.size) {
  log.warn('SAFETY: catastrophic drop detected — aborting drop pass', {
    dropCount: dropActions.length,
    boardSize: kanbanFilenames.size,
  });
  dropActions.length = 0;
}

More than five drops, and more drops than cards left on the board, means one pass would close more than half of the active tasks. The daemon aborts that drop pass. Status changes and new cards still go through, and only the mass drop is blocked.

4. Backup and stale locks

Two smaller fixes came with these. Every state write first copies the current file to state.json.bak, so recovery is cp state.json.bak state.json and a restart. If the process crashes without releasing its lock, the next start checks whether that PID is still alive before it blocks. A dead PID means a stale lock, which the daemon clears before it carries on.


How it works now

  • Change a task’s status in Notion on your phone, and within 5 minutes the local .md file reflects it and the board is rebuilt.
  • Edit a task body in Obsidian, and the change reaches Notion within 500 ms.
  • Drop a new .md file into the folder, and a Notion page is created; the file gets its notion_id back.
  • Drag a card to another column, and the status reaches Notion within about a second; the local file updates on the next poll.
  • Click a card, and Obsidian opens its .md file; edits to the title or body reach Notion within 500 ms.

Each card on the board is a wikilink to its own .md file rather than a self-contained card. That mirrors how Notion works, where the board is a view and the content lives in the page. Dragging handles status changes, a full editor handles everything else, and I never have to leave Obsidian.


Instant in both directions

One asymmetry remained after the wipeout. Local edits reach Notion within 500 ms, but a change made in Notion, say on my phone, only arrives locally with the next 5-minute poll. The daemon has an HTTP trigger endpoint for exactly this case: POST /trigger-poll runs a poll immediately instead of waiting for the interval. I had exposed it through a Cloudflare Tunnel and built an n8n workflow to call it whenever Notion fires a change event. Then I left the workflow inactive and never tested it.

When I finally went to turn it on, I found the workflow set to responseMode: "immediately", a value n8n does not support. That setting, not the inactive switch, was why the webhook returned 404, and one field change made the chain live. It was untested automation again, the same sin that cost me 28 tasks, only with a smaller blast radius.

Before switching it on I also added a concurrency guard. The daemon calls poll() from two places, the setInterval scheduler and the HTTP trigger handler, and nothing stopped the two from running at the same time. The fix is a module-level pollInFlight flag: whichever caller finds it set skips its run and logs a line. Eight lines of code close the whole surface.

The tested chain starts with a Notion change event, which calls the n8n webhook. n8n reaches POST /trigger-poll through the Cloudflare Tunnel, and the daemon runs an immediate poll and rebuilds kanban.md. The round trip takes a few seconds, so both directions are now effectively instant.


The code

The daemon is open source: github.com/waldo-van-der-code/notion-obsidian-sync. It is Node.js, about 1,100 lines across 10 files, with zero cloud dependencies, and it runs as a launchd agent on macOS or a systemd service on Linux. The README covers setup, configuration and the Notion database schema you need.


A pattern worth naming

Each fix follows the same logic. Every time the daemon made a mistake, I added a constraint that makes that specific mistake impossible from then on, a structural rule rather than a patch to the logic. Later I came across a piece by Decoding AI on agentic harness engineering that puts it well:

“Harness engineering is the practice of engineering a solution every time an agent makes a mistake, ensuring it never makes that specific mistake again.”

The guards are exactly that, and the idea holds for any automated process that takes real-world actions, LLM-powered or not. They have fired twice since the wipeout, both times because Obsidian’s startup race emptied the board again. Both times the sync suspended itself, logged a SAFETY: warning and recovered cleanly on the next poll.