---
title: Syncing Completed Things 3 Tasks into Obsidian Daily Notes
description: "A Node script and launchd agent that copies yesterday's completed Things tasks into your Obsidian daily note automatically. Idempotent, safe, dry-run supported."
publishDate: 2026-02-17
canonical: https://www.mandalivia.com/obsidian/syncing-completed-things-3-tasks-into-obsidian-daily-notes/
---

<video autoplay loop muted playsinline aria-label="ThingsToObsidian">
  <source src="/garden-assets/obsidian/ThingsToObsidian.mp4" type="video/mp4" />
</video>


Every morning I want to see what I actually accomplished yesterday — not what I planned, but what I finished. My task manager (Things 3) knows this, but my daily notes don't. So I built a small script to bridge the gap.

## The Problem

Things and Obsidian don't talk to each other. Tasks get completed in Things throughout the day, but my daily note — where I do my nightly review — has no record of them. I like having a log of what I got done in my daily note.

## The Solution

A Node CLI script that runs once daily via macOS `launchd`. It pulls yesterday's completed tasks from Things using [things-cli](https://github.com/thingsapi/things-cli) (a Python tool that reads directly from the Things 3 SQLite database) and inserts them into yesterday's daily note under a dedicated heading.

The whole thing is intentionally conservative: if anything looks wrong, it skips rather than corrupts.

## How It Works

1. **Determine yesterday's date** and locate the corresponding daily note
2. **Check guards** — skip if it already ran for that date, if it's too early, or if the daily note doesn't exist
3. **Query Things** via `things-cli` for the logbook in JSON format
4. **Filter** to only completed to-dos from yesterday
5. **Format** each task as a checked markdown item
6. **Insert** the block into the daily note under a specific anchor heading
7. **Record** the successful date to a state file so it doesn't run twice

### What It Produces

The script finds an anchor heading in your daily note (I use my "Nightly Review" section) and inserts a block like this just before the next section:

```markdown
### Things Actions
- [x] Ship feature flag for dashboard _(Project: App v2)_
- [x] Reply to landlord about lease _(Area: Personal)_
- [x] Read chapter 5 of Designing Data-Intensive Applications
```

Each task shows its title, and optionally the project and area it belonged to. If the section already exists, it skips — no duplicates.

### Key Design Decisions

**Skip, never corrupt.** If the anchor heading doesn't exist, or the section was already written, or the note is missing — it skips silently. No partial writes, no guessing.

**Idempotent by default.** A state file tracks the last successful sync date. Running the script multiple times on the same day does nothing after the first success.

**Dry-run first.** A `--dry-run` flag shows exactly what would happen without touching any files. I tested extensively with this before letting it write.

### Automation

The script runs daily at 5:15am via a macOS LaunchAgent. `RunAtLoad` is set to true so it also fires after a reboot — you don't miss a day. Logs go to `~/Library/Logs/` for debugging.

One thing worth knowing: LaunchAgents run in a minimal shell environment. You need to explicitly set `PATH` and tool paths in the plist — don't count on your `.zshrc` being sourced.

### Configuration

Everything is configurable via flags or environment variables:

| Setting | Flag | Env Var | Default |
|---|---|---|---|
| Vault path | `--vault-path` | `OBSIDIAN_VAULT_PATH` | (your vault root) |
| Earliest run hour | `--min-hour` | `ODC_MIN_HOUR` | `5` |
| Things CLI path | `--things-cmd` | `THINGS_CMD` | `things-cli` |
| State file | `--state-file` | `ODC_STATE_FILE` | `~/.local/state/...` |

## The Script

```javascript
#!/usr/bin/env node

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');

// --- Configuration defaults (override via flags or env vars) ---

const DEFAULT_VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH || '';
const DEFAULT_MIN_HOUR = 5;
const NIGHTLY_REVIEW_HEADING = '## 🌘 Nightly Review';
const THINGS_ACTIONS_HEADING = '### Things Actions';

function parseArgs(argv) {
  const args = {
    dryRun: false,
    force: false,
    verbose: false,
    vaultPath: DEFAULT_VAULT_PATH,
    minHour: Number(process.env.ODC_MIN_HOUR || DEFAULT_MIN_HOUR),
    thingsCmd: process.env.THINGS_CMD || 'things-cli',
    stateFile: process.env.ODC_STATE_FILE ||
      path.join(os.homedir(), '.local/state/obsidian-daily-catchup/last-successful-target-date.txt'),
  };

  for (let i = 2; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === '--dry-run' || arg === '-n') args.dryRun = true;
    else if (arg === '--force') args.force = true;
    else if (arg === '--verbose') args.verbose = true;
    else if (arg === '--vault-path') { args.vaultPath = argv[++i]; }
    else if (arg === '--min-hour') { args.minHour = Number(argv[++i]); }
    else if (arg === '--things-cmd') { args.thingsCmd = argv[++i]; }
    else if (arg === '--state-file') { args.stateFile = argv[++i]; }
    else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); }
    else throw new Error(`Unknown argument: ${arg}`);
  }

  if (!args.vaultPath) throw new Error('--vault-path or OBSIDIAN_VAULT_PATH is required');
  return args;
}

function printHelp() {
  process.stdout.write(`Usage: obsidian-daily-catchup [options]

Adds yesterday's completed Things tasks into yesterday's Obsidian daily note.
Safe by default: when in doubt, it skips without changing files.

Options:
  -n, --dry-run         Print planned actions, do not write
      --force           Ignore the before-5am guard
      --verbose         Extra logging
      --vault-path PATH Vault root (required, or set OBSIDIAN_VAULT_PATH)
      --min-hour HOUR   Earliest local hour to run (default: 5)
      --things-cmd CMD  Things CLI command (default: things-cli)
      --state-file PATH State file path
  -h, --help            Show help
`);
}

function log(msg) { process.stdout.write(`${msg}\n`); }
function debug(enabled, msg) { if (enabled) log(`[debug] ${msg}`); }

// --- Date helpers ---

function toDateKeyLocal(date) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

function monthFolder(date) {
  const num = String(date.getMonth() + 1).padStart(2, '0');
  const name = date.toLocaleString('en-US', { month: 'long' });
  return `${num}-${name}`;
}

// Adjust this to match your daily note path structure
function getDailyNotePath(vaultPath, targetDate) {
  return path.join(
    vaultPath, 'Planner', 'Daily Notes',
    String(targetDate.getFullYear()),
    monthFolder(targetDate),
    `${toDateKeyLocal(targetDate)}.md`
  );
}

// --- State file (idempotency) ---

function readState(stateFile) {
  try { return fs.readFileSync(stateFile, 'utf8').trim(); }
  catch (_) { return ''; }
}

function writeState(stateFile, dateKey) {
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });
  fs.writeFileSync(stateFile, `${dateKey}\n`, 'utf8');
}

// --- Things CLI interaction ---

function runThingsCommands(thingsCmd, verbose) {
  const candidates = [
    ['-j', 'logbook'], ['--json', 'logbook'],
    ['-j', 'completed'], ['--json', 'completed'],
    ['logbook', '--json'], ['completed', '--json'],
  ];

  for (const candidate of candidates) {
    const result = spawnSync(thingsCmd, candidate, {
      encoding: 'utf8', maxBuffer: 256 * 1024 * 1024,
    });
    debug(verbose, `Tried: ${thingsCmd} ${candidate.join(' ')} -> exit ${result.status}`);
    if (result.error || result.status !== 0) continue;
    const stdout = (result.stdout || '').trim();
    if (!stdout) continue;
    try {
      return { ok: true, data: JSON.parse(stdout), command: `${thingsCmd} ${candidate.join(' ')}` };
    } catch (_) {
      debug(verbose, `Non-JSON output from: ${thingsCmd} ${candidate.join(' ')}`);
    }
  }

  return { ok: false, reason: 'Unable to retrieve JSON from Things CLI.' };
}

// --- Task extraction ---

function parseDateLike(value) {
  if (!value || typeof value !== 'string') return null;
  const parsed = new Date(value);
  return Number.isNaN(parsed.getTime()) ? null : parsed;
}

function extractCompletedDateKey(task) {
  for (const key of ['completed', 'completed_at', 'completion_date', 'completedDate', 'stop_date']) {
    const dt = parseDateLike(task[key]);
    if (dt) return toDateKeyLocal(dt);
  }
  return '';
}

function normalizeTask(task) {
  const title = String(task.title || task.name || '').trim();
  if (!title) return null;
  if (task.status && String(task.status).toLowerCase() !== 'completed') return null;
  if (task.type && String(task.type).toLowerCase() !== 'to-do') return null;

  return {
    title,
    project: String(task.project || task.project_title || task.list || '').trim(),
    area: String(task.area_title || task.area || '').trim(),
    completedDateKey: extractCompletedDateKey(task),
  };
}

// Recursively walk the Things JSON (handles nested projects)
function walkAny(node, out) {
  if (Array.isArray(node)) { for (const item of node) walkAny(item, out); return; }
  if (!node || typeof node !== 'object') return;
  const norm = normalizeTask(node);
  if (norm) out.push(norm);
  for (const value of Object.values(node)) {
    if (value && typeof value === 'object') walkAny(value, out);
  }
}

function collectTasksFromThings(data, targetDateKey) {
  const raw = [];
  walkAny(data, raw);
  const unique = new Map();
  for (const t of raw) {
    if (t.completedDateKey !== targetDateKey) continue;
    const key = `${t.title}|${t.project}|${t.area}`;
    if (!unique.has(key)) unique.set(key, t);
  }
  return [...unique.values()].sort((a, b) => a.title.localeCompare(b.title));
}

// --- Note modification ---

function formatTaskLine(task) {
  const safeTitle = task.title.replace(/\s+/g, ' ').trim();
  const meta = [];
  if (task.project) meta.push(`Project: ${task.project.replace(/\s+/g, ' ').trim()}`);
  if (task.area) meta.push(`Area: ${task.area.replace(/\s+/g, ' ').trim()}`);
  return meta.length === 0
    ? `- [x] ${safeTitle}`
    : `- [x] ${safeTitle} _(${meta.join(', ')})_`;
}

function buildUpdatedNote(original, taskLines) {
  const lines = original.split('\n');

  if (lines.some((line) => line.trim() === THINGS_ACTIONS_HEADING)) {
    return { ok: false, reason: `${THINGS_ACTIONS_HEADING} already exists; skipping.` };
  }

  const nightlyIdx = lines.findIndex((line) => line.trim() === NIGHTLY_REVIEW_HEADING);
  if (nightlyIdx === -1) {
    return { ok: false, reason: `Missing ${NIGHTLY_REVIEW_HEADING}; skipping.` };
  }

  const nextH2 = lines.findIndex((line, i) => i > nightlyIdx && /^##\s+/.test(line));
  const insertAt = nextH2 === -1 ? lines.length : nextH2;

  const block = ['', THINGS_ACTIONS_HEADING, ...taskLines, ''];
  return {
    ok: true,
    text: [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)].join('\n'),
  };
}

// --- Main ---

function main() {
  let args;
  try { args = parseArgs(process.argv); }
  catch (err) { process.stderr.write(`${err.message}\n`); printHelp(); process.exit(1); }

  const now = new Date();
  const targetDate = new Date(now);
  targetDate.setDate(now.getDate() - 1);

  const todayKey = toDateKeyLocal(now);
  const targetDateKey = toDateKeyLocal(targetDate);
  const notePath = getDailyNotePath(args.vaultPath, targetDate);

  log(`Target date: ${targetDateKey} | Mode: ${args.dryRun ? 'dry-run' : 'apply'}`);

  if (!args.force && now.getHours() < args.minHour) {
    log(`Skip: too early (hour ${now.getHours()} < ${args.minHour}).`); return;
  }
  if (targetDateKey === todayKey) {
    log('Skip: safety guard prevented targeting today.'); return;
  }
  if (readState(args.stateFile) === targetDateKey) {
    log(`Skip: already synced ${targetDateKey}.`); return;
  }
  if (!fs.existsSync(notePath)) {
    log(`Skip: no daily note for ${targetDateKey}.`); return;
  }

  const noteText = fs.readFileSync(notePath, 'utf8');
  const thingsResult = runThingsCommands(args.thingsCmd, args.verbose);
  if (!thingsResult.ok) { log(`Skip: ${thingsResult.reason}`); return; }

  const tasks = collectTasksFromThings(thingsResult.data, targetDateKey);
  if (tasks.length === 0) { log(`Skip: no tasks for ${targetDateKey}.`); return; }

  log(`Found ${tasks.length} completed task(s).`);
  const taskLines = tasks.map(formatTaskLine);
  const update = buildUpdatedNote(noteText, taskLines);
  if (!update.ok) { log(`Skip: ${update.reason}`); return; }

  if (args.dryRun) {
    log('[dry-run] Would insert:');
    log(THINGS_ACTIONS_HEADING);
    for (const line of taskLines) log(line);
    return;
  }

  fs.writeFileSync(notePath, update.text, 'utf8');
  writeState(args.stateFile, targetDateKey);
  log('Done.');
}

main();
```

## Caveats

- **macOS only.** Uses `launchd` for scheduling and `things-cli` for task data.
- **Daily note path is baked in.** The script assumes a specific folder structure (`Planner/Daily Notes/YYYY/MM-Month/YYYY-MM-DD.md`). Modify `getDailyNotePath()` to match yours.
- **Anchor heading must exist.** The script needs a heading in your daily note to know where to insert. Change the `NIGHTLY_REVIEW_HEADING` constant to match your template.
- **Requires [things-cli](https://github.com/thingsapi/things-cli).** Install via `pip install things-cli`. It reads directly from the Things 3 SQLite database.

## Related

[Nightly Toggl Summary in Your Obsidian Daily Note](/obsidian/nightly-toggl-summary-in-your-obsidian-daily-note/)
[Voice-Driven Time Tracking with Toggl and Claude](/obsidian/voice-driven-time-tracking-with-toggl-and-claude/)