# Todoist Two-Way Sync *Optional. macOS. Requires a Todoist account and a little Terminal use.* This keeps your Task notes (in `Activities/Tasks/`) and a Todoist project in sync **both directions**, continuously, via a small Python daemon managed by macOS `launchd`. Create a task in Obsidian and it appears in Todoist within a second; check it off in Todoist and the note flips to `status: done` within 30 seconds. The complete source for each file is on its own page: [[Todoist Sync Script]] (`todoist_sync.py`), [[Todoist launchd Agent]] (the plist), and [[Todoist Deploy Script]] (the installer). ## What syncs | Obsidian Task note | ↔ | Todoist task | |---|---|---| | Filename (after the date prefix) | ↔ | Task name | | `due:` | ↔ | Due date | | `status: open`/`done` | ↔ | Open / completed | | Note body | ↔ | Description | | New note | → | New task (and the `todoist_id` is written back) | | New task | → | New note in `Activities/Tasks/` | **Conflict policy:** last-write-wins by modification time; ties go to Todoist. Deleting a note labels and closes the Todoist task; deleting a Todoist task unlinks the note (clears its `todoist_id`) but leaves it intact. ## How it works - A **file watcher** pushes Obsidian changes to Todoist near-instantly. - A **30-second poller** uses the Todoist Sync API (incrementally, via a sync token) to pull Todoist changes back. - A **state file** (`~/.config/<config-name>/state.json`) maps Todoist task IDs to note paths and remembers the last-synced hash on each side, so it only acts on real changes. - A "just-wrote" guard suppresses the echo of changes the daemon itself just made, preventing loops. ## Prerequisites - [`uv`](https://docs.astral.sh/uv/) — a fast Python environment manager. Install with `brew install uv` or `curl -LsSf https://astral.sh/uv/install.sh | sh`. - A **Todoist API token**: Todoist → Settings → Integrations → Developer → copy the token. - A Todoist **project** to sync into (e.g. one named "Obsidian"). The script creates it if it doesn't exist. > [!warning] First-run behavior > On first start, **every Task note without a `todoist_id:` becomes a new Todoist task.** Delete or archive any tasks you don't want pushed before you start. ## Setup ### 1. Personalize the files On the [[Todoist Sync Script]] page, copy `todoist_sync.py` and edit the per-vault constants near the top: ```python VAULT_ROOT = Path("/Users/YOUR_USERNAME/Library/Mobile Documents/iCloud~md~obsidian/Documents/YOUR_VAULT") CONFIG_NAME = "obsidian-todoist-sync" DEFAULT_PROJECT_NAME = "Obsidian" ``` Edit the same placeholders in the plist and `deploy-todoist-sync.sh`. Rename the plist to match its internal label: **`local.obsidian-todoist-sync.plist`**. ### 2. Use the deploy script (recommended) `deploy-todoist-sync.sh` automates the rest. Make it executable and run it: ```bash chmod +x deploy-todoist-sync.sh ./deploy-todoist-sync.sh ``` It prompts once for your Todoist token, then: - creates the shared Python venv at `~/.venvs/todoist-sync` (only if missing) and installs `requests`, `watchdog`, `python-frontmatter`; - writes `~/.config/obsidian-todoist-sync/config.json` (leaving an existing one untouched, so your token is never clobbered); - copies the plist into `~/Library/LaunchAgents/` and loads it. It's safe to re-run. To sync more than one vault into more than one project, add a row to the `VAULTS` array (the script's comments show the format) — one row per vault → project. ### 3. Grant Full Disk Access (one time) `launchd`-spawned processes can't read iCloud Drive until you allow it: 1. **System Settings → Privacy & Security → Full Disk Access → +** 2. Press **Cmd-Shift-G**, paste `~/.venvs/todoist-sync/bin/python`, and add it. 3. Toggle it **on**, then re-run the deploy script (or reload the agent). Without this you'll see `Operation not permitted` in `~/.config/obsidian-todoist-sync/stderr.log`. ### Manual setup (if you'd rather not use the deploy script) ```bash # venv uv venv --python 3.12 ~/.venvs/todoist-sync uv pip install --python ~/.venvs/todoist-sync/bin/python requests watchdog python-frontmatter # config mkdir -p ~/.config/obsidian-todoist-sync cat > ~/.config/obsidian-todoist-sync/config.json <<'EOF' { "todoist_token": "PASTE_YOUR_TOKEN", "project_name": "Obsidian" } EOF chmod 600 ~/.config/obsidian-todoist-sync/config.json # test one sweep before installing the daemon ~/.venvs/todoist-sync/bin/python "/path/to/YOUR_VAULT/Scripts/todoist_sync.py" --once # install the launchd agent cp local.obsidian-todoist-sync.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/local.obsidian-todoist-sync.plist ``` ## Syncing multiple Todoist projects **The unit of sync is one vault ↔ one Todoist project.** Each running sync watches a single vault's `Activities/Tasks/` folder and mirrors it to a single Todoist project. To sync several projects, you run several independent syncs — one per vault. They share one Python environment but otherwise never touch each other: each has its own config directory, state file, log, `launchd` agent, and Todoist project. > [!note] One vault → one project > A single vault's tasks all go to one project; you can't split one vault across two projects out of the box. If you want that, you'd extend `todoist_sync.py` to pick the project from a task's frontmatter — see [[10 Adapting and Extending]]. For most people, "multiple projects" simply means "multiple vaults," which is what this section covers. ### What must be unique per vault | Setting | Where it lives | Unique per vault? | |---|---|---| | `VAULT_ROOT` | `todoist_sync.py` constants | Yes — each vault's own path | | `CONFIG_NAME` | `todoist_sync.py` constants | **Yes** — names its `~/.config/<name>/` dir, state file, and logs | | `DEFAULT_PROJECT_NAME` | `todoist_sync.py` constants | The Todoist project (distinct, unless you *want* two vaults merged into one project) | | `Label` + plist filename | the `.plist` | **Yes** — `launchd` rejects duplicate labels | | Todoist API token | `config.json` | No — the **same** token for all (one Todoist account) | The single most important rule: **give each vault a distinct `CONFIG_NAME` and `Label`.** Reuse them and two vaults will share one state file and one agent, and clobber each other. ### Worked example: two vaults, two projects Say you keep a **Work** vault (→ Todoist project "Work") and a **Personal** vault (→ project "Personal"), both under iCloud. **Step 1 — give each vault its own `todoist_sync.py`.** Copy the script (from [[Todoist Sync Script]]) into each vault's `Scripts/` folder and set the three constants. `…/Documents/Work/Scripts/todoist_sync.py`: ```python VAULT_ROOT = Path("/Users/YOUR_USERNAME/Library/Mobile Documents/iCloud~md~obsidian/Documents/Work") CONFIG_NAME = "work-todoist-sync" DEFAULT_PROJECT_NAME = "Work" ``` `…/Documents/Personal/Scripts/todoist_sync.py`: ```python VAULT_ROOT = Path("/Users/YOUR_USERNAME/Library/Mobile Documents/iCloud~md~obsidian/Documents/Personal") CONFIG_NAME = "personal-todoist-sync" DEFAULT_PROJECT_NAME = "Personal" ``` **Step 2 — give each vault its own plist.** Copy the plist (from [[Todoist launchd Agent]]) into each vault's `Scripts/` folder, named `<label>.plist`, with a matching `Label`, script path, and log paths. `…/Documents/Work/Scripts/local.work-todoist-sync.plist` (the lines that change): ```xml <key>Label</key> <string>local.work-todoist-sync</string> ... <string>/Users/YOUR_USERNAME/Library/Mobile Documents/iCloud~md~obsidian/Documents/Work/Scripts/todoist_sync.py</string> ... <string>/Users/YOUR_USERNAME/.config/work-todoist-sync/stdout.log</string> <string>/Users/YOUR_USERNAME/.config/work-todoist-sync/stderr.log</string> ``` The Personal vault's plist is identical with `work` → `personal` and `Work` → `Personal` throughout. **Step 3 — list both vaults in the deploy script.** Edit the `VAULTS` array in `deploy-todoist-sync.sh` (from [[Todoist Deploy Script]]) — one row per vault, formatted `vault-folder|config-name|project|launchd-label`: ```bash VAULTS=( "Work|work-todoist-sync|Work|local.work-todoist-sync" "Personal|personal-todoist-sync|Personal|local.personal-todoist-sync" ) ``` **Step 4 — run it once.** ```bash ./deploy-todoist-sync.sh ``` For each row it writes `~/.config/<config-name>/config.json` (same token, that row's project), copies that vault's plist into `~/Library/LaunchAgents/`, and loads it. The shared venv is created on the first row and reused. **Result — two independent daemons:** ``` $ launchctl list | grep todoist-sync 24871 0 local.work-todoist-sync 24872 0 local.personal-todoist-sync ``` You now have two config dirs (`~/.config/work-todoist-sync/` and `~/.config/personal-todoist-sync/`), two state files, two sync tokens, and two Todoist projects — fully isolated. Adding a third vault is just another file trio (script + plist + project) and one more `VAULTS` row. ### What the deploy script does and doesn't do For every `VAULTS` row, the script looks for the plist at `…/Documents/<vault-folder>/Scripts/<label>.plist`. If it isn't there (wrong name or wrong folder), that row is **skipped** with a message. The deploy script writes the `config.json` files and loads the agents — it does **not** create the per-vault `todoist_sync.py` or plist for you. That's Steps 1–2; do them first. ### Common mistakes - **Same `CONFIG_NAME` for two vaults** → they share `state.json` and fight over it. Always distinct. - **Same `Label` for two vaults** → the second `launchctl load` clobbers the first. Always distinct. - **Same project name for two vaults** → both vaults' tasks pour into one Todoist project (occasionally intended — usually not). - **Plist not named exactly `<label>.plist` in the vault's `Scripts/`** → the deploy script skips that row. - **Edited a script or plist after loading** → re-run the deploy script (or `launchctl unload && load`) so the change takes effect. ## Managing the daemon ```bash # verify it's running (expect a numeric PID) launchctl list | grep todoist-sync # stop / start / reload (after editing the script or plist) launchctl unload ~/Library/LaunchAgents/local.obsidian-todoist-sync.plist launchctl load ~/Library/LaunchAgents/local.obsidian-todoist-sync.plist # logs tail -f ~/.config/obsidian-todoist-sync/sync.log # the script's own log tail -f ~/.config/obsidian-todoist-sync/stderr.log # launchd-captured crashes ``` ## Gotchas - **`Operation not permitted`** → Full Disk Access wasn't granted to the venv's python; redo step 3 and reload. - **Manually linking an existing Todoist task** → add `todoist_id: <id>` to a note's frontmatter; the next sync pairs them instead of creating a duplicate. - **Renames** → editing a note's filename (after the date prefix) renames the Todoist task, and vice-versa. - **Multiple vaults / projects** → see *Syncing multiple Todoist projects* above; each vault gets its own config dir, state file, plist label, and project, so they never collide. Next: [[10 Adapting and Extending|Adapting & Extending]].