Automating Work Item Sync to Obsidian
I do my thinking in Obsidian. Plans, debugging notes, half-formed ideas, links between things — it all lives in one vault. But the work I get paid to do at Salesforce lives in GUS, our internal ticketing system. Every time I picked up a new ticket, the ritual was the same: skim the description, then hand-type a note in Obsidian to track my own thinking.
That friction sounds small. But small frictions are what keep you from writing things down, and not writing things down is how you forget what you learned three months ago. So I built a pipeline to make GUS feel like it's already inside the vault.
The rule: every ticket assigned to me should exist as a note, and the metadata should never be more than ten minutes stale. Nothing I write in the note myself should ever get overwritten.
How it Works
One Python script, one launchd plist. launchd runs the script every ten minutes. The script queries GUS via SOQL through the Salesforce CLI (sf data query --target-org gus --json), then creates or updates a note per ticket. No OAuth code, no refresh tokens — just a CLI someone else already wrote.
graph LR
A[launchd every 10m] --> B[sync_gus_work_items.py]
B --> C[sf data query --target-org gus]
C --> D{Note exists?}
D -->|No| E[Create from t_work_item template]
D -->|Yes| F[Open existing]
E --> G[obsidian property:set for each field]
F --> G
G --> H[HTML → markdown, obsidian prepend]
H --> I[Write .last_gus_sync timestamp]
Each record becomes a note named W-XXXXXXXX <Subject>.md in Notes/. If the note doesn't exist, it creates one from my work-item template. If it does, it updates the frontmatter properties and description.
I got the file-writing part wrong at first. I tried writing markdown and frontmatter from Python. YAML has fiddly rules — wikilinks need quoting, the Linter plugin reformats files on save and undoes anything subtly off. I kept finding corrupted notes. The fix was to use the Obsidian CLI instead. Every write goes through Obsidian's own parser via IPC, so the files are always well-formed. The price is Obsidian has to be running, but it always is.
Incremental Sync
The script keeps a single timestamp in .last_gus_sync at the vault root. Each run it splices that into the SOQL WHERE clause as LastModifiedDate > ... and usually gets back zero to five rows. The whole sync finishes in a second or two. Pass --full to force a complete re-sync.
There's a deliberate asymmetry in sync direction. Properties and the GUS description are always overwritten from upstream. But the content I write below the description — investigation notes, Splunk queries, links to PRs — is never touched. The sync uses prepend, which inserts after frontmatter and before existing body. My notes stay put.
This matters because it means the ticket page in Obsidian becomes the single place where upstream facts and my own thinking coexist, and I never worry a sync will stomp on my work.
The Payoff
The real payoff is the Base I built on top of the synced notes. Bases/Work Items.base filters every note linking to Salesforce, groups by sprint, and sorts by priority. Because every synced field lives in frontmatter, the Base is always accurate without me maintaining it. I open Obsidian in the morning and my day's queue is already there.
When I'm debugging something, I append to the ticket's note — commits, logs, bad queries, theories that turned out wrong. Three months later when a regression comes back, I can actually find what I learned the first time.
A few things I think generalize. When the destination has a CLI that understands its own file format, use it — writing through Obsidian itself was easy where writing files from Python was a dead end. When the source has a CLI, use that too. For sync jobs, a single timestamp is usually enough state. And decide for each section whether the source of truth is upstream or local, then enforce it. The script is maybe 300 lines. It's not clever. But it removes one of the most persistent small frictions in my workflow.