|
| 1 | +# Obsidian ↔ Dreams Sync — Home-Server Agent Setup |
| 2 | + |
| 3 | +How to configure an external agent (running on your home server) to keep a |
| 4 | +dreams folder in an Obsidian vault in sync with the Ta-Da! instance. |
| 5 | + |
| 6 | +The sync is driven by a standalone Node script |
| 7 | +([app/scripts/sync-obsidian.mjs](../../app/scripts/sync-obsidian.mjs)) that |
| 8 | +talks to the Ta-Da! API over HTTPS with a Bearer token. No Obsidian plugin |
| 9 | +is required — the script reads and writes `.md` files directly. |
| 10 | + |
| 11 | +Production base URL: `https://tada.living` |
| 12 | + |
| 13 | +Replace `https://tada.living` below if running against a different |
| 14 | +deployment (e.g. `http://localhost:3000` for local dev). |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Prerequisites |
| 19 | + |
| 20 | +1. **Node.js 20+** on the home server (the script uses `node:util` |
| 21 | + `parseArgs` and top-level `await`). |
| 22 | +2. **A checkout of the repo**, or just the file |
| 23 | + `app/scripts/sync-obsidian.mjs` dropped onto the home server — it has |
| 24 | + no third-party dependencies. |
| 25 | +3. **A writable Obsidian vault path** the agent can read and write. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Step 1 — Mint an API key |
| 30 | + |
| 31 | +The agent authenticates with a Bearer API key that has two permissions: |
| 32 | + |
| 33 | +- `entries:read` — list and read entries |
| 34 | +- `entries:write` — create and update entries |
| 35 | + |
| 36 | +Open devtools on any `https://tada.living/*` page while signed in, and |
| 37 | +run: |
| 38 | + |
| 39 | +```js |
| 40 | +await fetch("https://tada.living/api/v1/auth/keys", { |
| 41 | + method: "POST", |
| 42 | + credentials: "include", |
| 43 | + headers: { "Content-Type": "application/json" }, |
| 44 | + body: JSON.stringify({ |
| 45 | + name: "home-server obsidian sync", |
| 46 | + permissions: ["entries:read", "entries:write"], |
| 47 | + }), |
| 48 | +}).then((r) => r.json()); |
| 49 | +``` |
| 50 | + |
| 51 | +The response includes a `key` of the form `tada_key_…`. **This is the |
| 52 | +only time the plaintext key is shown.** Copy it to the home-server |
| 53 | +agent's secret store immediately. |
| 54 | + |
| 55 | +> Do not grant `admin:*` scopes for this agent. Scope the key to the |
| 56 | +> minimum it needs. |
| 57 | +
|
| 58 | +--- |
| 59 | + |
| 60 | +## Step 2 — Lay out the vault |
| 61 | + |
| 62 | +The script expects a subfolder inside the vault for its managed files |
| 63 | +(default: `tada/`). For dreams specifically, a nested folder like |
| 64 | +`tada/dreams/` keeps things tidy: |
| 65 | + |
| 66 | +``` |
| 67 | +MyVault/ |
| 68 | +├── .tada-sync.json ← optional config file |
| 69 | +├── .tada-sync-state.json ← auto-created by the script (do not edit) |
| 70 | +└── tada/ |
| 71 | + └── dreams/ |
| 72 | + ├── 2026-04-21-waning-gibbous.md |
| 73 | + └── … |
| 74 | +``` |
| 75 | + |
| 76 | +The script will create `tada/dreams/` on first run if it doesn't exist. |
| 77 | +It also creates `tada/dreams/.trash/` for files whose underlying entries |
| 78 | +have been deleted in Ta-Da!. |
| 79 | + |
| 80 | +--- |
| 81 | + |
| 82 | +## Step 3 — Configure the agent |
| 83 | + |
| 84 | +Two ways to supply config, which can be mixed. CLI flags always win over |
| 85 | +config-file values, and env vars are the fallback. |
| 86 | + |
| 87 | +### Option A — `.tada-sync.json` in the vault root (auto-loaded) |
| 88 | + |
| 89 | +```json |
| 90 | +{ |
| 91 | + "apiUrl": "https://tada.living", |
| 92 | + "apiKey": "tada_key_…", |
| 93 | + "syncFolder": "tada/dreams", |
| 94 | + "categories": ["journal"], |
| 95 | + "subcategories": ["dream"], |
| 96 | + "fileNamePattern": "{{date}}-{{name}}.md", |
| 97 | + "pushDeletes": false |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +### Option B — environment variables + CLI flags |
| 102 | + |
| 103 | +```bash |
| 104 | +export TADA_API_KEY=tada_key_… |
| 105 | +export TADA_API_URL=https://tada.living |
| 106 | + |
| 107 | +node scripts/sync-obsidian.mjs \ |
| 108 | + --vault /home/me/MyVault \ |
| 109 | + --sync-folder tada/dreams \ |
| 110 | + --subcategories dream |
| 111 | +``` |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +## Step 4 — Run the sync |
| 116 | + |
| 117 | +### Normal bidirectional sync |
| 118 | + |
| 119 | +```bash |
| 120 | +node scripts/sync-obsidian.mjs --vault /home/me/MyVault |
| 121 | +``` |
| 122 | + |
| 123 | +### Dry run (recommended for first invocation) |
| 124 | + |
| 125 | +Prints what _would_ happen without writing anything to the vault or the |
| 126 | +API: |
| 127 | + |
| 128 | +```bash |
| 129 | +node scripts/sync-obsidian.mjs --vault /home/me/MyVault --dry-run --verbose |
| 130 | +``` |
| 131 | + |
| 132 | +### One-way only |
| 133 | + |
| 134 | +- `--direction pull` — Ta-Da! → vault only (safe on a new vault). |
| 135 | +- `--direction push` — vault → Ta-Da! only. |
| 136 | +- `--direction both` (default) — pull first, then push. |
| 137 | + |
| 138 | +### Scheduling |
| 139 | + |
| 140 | +Drop it in cron on the home server, e.g. every 15 minutes: |
| 141 | + |
| 142 | +```cron |
| 143 | +*/15 * * * * /usr/bin/node /opt/tada/scripts/sync-obsidian.mjs \ |
| 144 | + --config /home/me/MyVault/.tada-sync.json >> /var/log/tada-sync.log 2>&1 |
| 145 | +``` |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## CLI reference |
| 150 | + |
| 151 | +| Flag | Short | Default | Notes | |
| 152 | +|------|-------|---------|-------| |
| 153 | +| `--vault <path>` | `-v` | — | **Required.** Absolute path to the Obsidian vault | |
| 154 | +| `--api-key <key>` | `-k` | `$TADA_API_KEY` | **Required** via flag, config, or env | |
| 155 | +| `--api-url <url>` | `-u` | `http://localhost:3000` | Override with `$TADA_API_URL` or config | |
| 156 | +| `--config <path>` | `-c` | — | Path to a config JSON file | |
| 157 | +| `--direction <dir>` | `-d` | `both` | `pull`, `push`, or `both` | |
| 158 | +| `--dry-run` | — | `false` | Preview without writing | |
| 159 | +| `--categories <list>` | — | — | Comma-separated category filter (pull + push) | |
| 160 | +| `--subcategories <list>` | — | — | Comma-separated subcategory filter (pull + push) | |
| 161 | +| `--sync-folder <path>` | — | `tada` | Subfolder inside the vault the script owns | |
| 162 | +| `--file-pattern <pat>` | — | `{{date}}-{{name}}.md` | Filename template — see below | |
| 163 | +| `--verbose` | — | `false` | Per-file log lines | |
| 164 | +| `--help` | `-h` | — | Show inline help | |
| 165 | + |
| 166 | +**Filename template placeholders**: `{{date}}` (YYYY-MM-DD from |
| 167 | +timestamp), `{{name}}` (slugified entry name), `{{category}}`, |
| 168 | +`{{subcategory}}`, `{{id}}` (first 8 chars of entry id). |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## Markdown format |
| 173 | + |
| 174 | +Each synced entry is a `.md` file with YAML frontmatter followed by an |
| 175 | +H1 title and body. For a dream: |
| 176 | + |
| 177 | +```markdown |
| 178 | +--- |
| 179 | +tada_id: abc123… |
| 180 | +type: moment |
| 181 | +category: journal |
| 182 | +subcategory: dream |
| 183 | +timestamp: 2026-04-21T08:00:00+01:00 |
| 184 | +tags: [lucid, flying] |
| 185 | +source: obsidian |
| 186 | +--- |
| 187 | + |
| 188 | +# Flying over the house |
| 189 | + |
| 190 | +I was skimming the rooftops, palm-up against the sky… |
| 191 | +``` |
| 192 | + |
| 193 | +- `tada_id` is the authoritative link back to the Ta-Da! entry. Leave it |
| 194 | + alone. If you author a new `.md` file in the sync folder and omit |
| 195 | + `tada_id`, the script will create a matching entry on the next push |
| 196 | + and stamp the id back into the frontmatter. |
| 197 | +- The H1 title becomes the entry `name`. Body below the H1 becomes |
| 198 | + `notes`. |
| 199 | +- Only frontmatter fields listed above round-trip. Extra keys are |
| 200 | + ignored on push and will be dropped on the next pull. |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## What counts as a "dream"? |
| 205 | + |
| 206 | +Dreams are ordinary Ta-Da! entries tagged with the category / |
| 207 | +subcategory the agent is configured to sync. The current convention: |
| 208 | + |
| 209 | +- `category: journal` |
| 210 | +- `subcategory: dream` |
| 211 | + |
| 212 | +Any entry matching that pair is pulled into the vault; any `.md` file |
| 213 | +whose frontmatter matches is pushed back up. If you change the |
| 214 | +convention on one side, update `categories` / `subcategories` in the |
| 215 | +agent's config on the same day or the sync will start skipping files. |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## Conflict resolution |
| 220 | + |
| 221 | +- **Remote newer than local (pull phase):** remote overwrites local. |
| 222 | +- **Local file modified since last sync, local `mtime` > remote |
| 223 | + `updatedAt`:** local wins — file is skipped this pull, will be pushed |
| 224 | + next run. |
| 225 | +- **Same content hash on both sides:** skipped (no-op). |
| 226 | + |
| 227 | +State is kept in `.tada-sync-state.json` inside the vault root. It maps |
| 228 | +each `tada_id` to its filename plus content hashes. Do **not** edit or |
| 229 | +delete this file unless you want to force a full resync. |
| 230 | + |
| 231 | +--- |
| 232 | + |
| 233 | +## Error cases |
| 234 | + |
| 235 | +| Message | Meaning | Fix | |
| 236 | +|---------|---------|-----| |
| 237 | +| `Cannot connect to Ta-Da! API at …` | API URL wrong or the instance is down | Check `--api-url` / `apiUrl` | |
| 238 | +| `API 401: …` | Key missing, revoked, or typo'd | Rotate key (Step 1) | |
| 239 | +| `API 403: …` | Key lacks `entries:read` or `entries:write` | Re-mint with correct permissions | |
| 240 | +| `Vault path does not exist: …` | `--vault` points at a missing directory | Fix the path | |
| 241 | +| `Failed to read config file: …` | Bad JSON or wrong path | Validate with `jq . < .tada-sync.json` | |
| 242 | + |
| 243 | +Non-zero exit code means at least one entry or file errored — check the |
| 244 | +log for `Error syncing entry …` or `Error pushing …` lines before the |
| 245 | +summary. |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## Rotating / revoking the key |
| 250 | + |
| 251 | +List your keys (browser console, logged in): |
| 252 | + |
| 253 | +```js |
| 254 | +await fetch("https://tada.living/api/v1/auth/keys", { |
| 255 | + credentials: "include", |
| 256 | +}).then((r) => r.json()); |
| 257 | +``` |
| 258 | + |
| 259 | +Revoke by id: |
| 260 | + |
| 261 | +```js |
| 262 | +await fetch("https://tada.living/api/v1/auth/keys/<KEY_ID>", { |
| 263 | + method: "DELETE", |
| 264 | + credentials: "include", |
| 265 | +}); |
| 266 | +``` |
| 267 | + |
| 268 | +Then redo Step 1 to mint a replacement and swap it into the agent's |
| 269 | +secret store (or `$TADA_API_KEY`). |
| 270 | + |
| 271 | +--- |
| 272 | + |
| 273 | +## Reference: live API docs |
| 274 | + |
| 275 | +- **Scalar UI:** https://tada.living/api-docs |
| 276 | +- **OpenAPI JSON:** https://tada.living/api/openapi.json |
| 277 | + |
| 278 | +Relevant endpoints this script uses: `GET /api/v1/sync/status`, |
| 279 | +`GET /api/v1/entries`, `POST /api/v1/entries`, |
| 280 | +`PATCH /api/v1/entries/{id}`. |
| 281 | + |
| 282 | +--- |
| 283 | + |
| 284 | +[Back to Modules](./README.md) |
0 commit comments