# 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]].