I write this blog in [Obsidian](./Obsidian%20as%20a%20Writing%20and%20Publishing%20System.md), and publishing used to mean the same manual ritual: open the **Publish changes** modal, hunt through a list of changed files, tick the right boxes, and click Publish. My vault has thousands of notes but I only publish a curated handful, so that modal feels like a minefield. I wanted to say "publish this post" and have exactly that happen.
I asked my agent to find a programmatic way to do it. It told me there was no official Obsidian Publish API or CLI, so we reverse-engineered the protocol used by the desktop client and built our own script.
That conclusion was wrong.
## The CLI my agent missed
[Obsidian CLI](https://obsidian.md/help/cli) entered Early Access on February 10, 2026 and became publicly available with Obsidian 1.12.4 on February 27. Its initial documentation already included commands to inspect, publish, and remove files from Obsidian Publish.
I began my automation work on June 27, four months after the public release:
| Milestone | Date | Relative timing |
| --- | --- | --- |
| Obsidian CLI Early Access, including Publish commands | February 10, 2026 | 137 days before my automation |
| Obsidian CLI public release | February 27, 2026 | 120 days before my automation |
| Dedicated CLI binary released with installer 1.12.7 | March 23, 2026 | 96 days before my automation |
| My AppleScript and Python automation created | June 27, 2026 | — |
The miss was partly obscured by how Obsidian updates itself. My application code had updated automatically, but the native installer on my Mac was still version 1.8.10. The improved CLI binary requires downloading a fresh installer rather than relying on the in-app update. Even so, the official CLI and its Publish commands were already documented. My research agent should have found them before recommending a reverse-engineered API.
## What I tried before reverse-engineering
Working from the incorrect premise that no official CLI existed, I tried two UI-level approaches:
- **Editing `publish.json`'s `included` list.** Obsidian holds that config in memory and writes it back, so external edits get clobbered. It also doesn't scope the publish modal the way I assumed.
- **A keyboard macro (AppleScript).** It can *open* the modal reliably, but it can't tick specific checkboxes in a list of thousands — the order isn't stable and there's no keyboard path to a given row.
Both were fragile because they automated the *UI*, which pushed me toward the protocol.
## What I built
The Obsidian desktop app is an Electron app, which means its source ships as a readable `app.js` inside an `.asar` archive. Extracting it and searching for the publish logic revealed how the client talks to the server:
```
POST https://publish-01.obsidian.md/api/upload
headers:
obs-token : account token
obs-id : site id
obs-path : URL-encoded, vault-relative file path
obs-hash : SHA-256 hex digest
body: raw file bytes
```
Each call uploads or updates exactly one file. That explicit scope was the behavior I wanted: uploading one post could not accidentally publish the rest of the vault, and uploading never deleted anything.
I also found the endpoints the client used to list and remove published paths. The result was a Python command that could publish several explicitly named files, list the live manifest, and remove explicitly named hosted paths. It worked, but it depended on a manually extracted account token, an undocumented protocol, and request details that could change without warning.
## How the official CLI compares
Once I found the official CLI, there was little reason to keep the reverse-engineered transport:
| Capability | Official Obsidian CLI | My Python script |
| --- | --- | --- |
| Publish one explicitly named file | `publish:add path=...` | Yes |
| Publish several explicit files | One command per path | One invocation |
| Unpublish an explicitly named file | `publish:remove path=...` | Yes |
| List published files | `publish:list` | Yes |
| Count published files | `publish:list total` | No |
| Detect pending changes | `publish:status` | No |
| Filter new, changed, and deleted paths | Yes | No |
| Show the site's slug and URL | `publish:site` | No |
| Open the published page | `publish:open` | No |
| Authentication | Managed by Obsidian | Manually extracted account token |
| Support status | Official | Reverse-engineered and brittle |
| Requires the desktop app | Yes | No |
| Local audit CSV | No | Yes |
My script had two conveniences the CLI does not natively document: batching several explicit paths in one process and writing an audit CSV. Neither justified retaining account-token handling and an unsupported network protocol.
## Switching to the official CLI
I have now removed the Python and AppleScript implementations and switched my publishing workflow to Obsidian CLI:
```bash
obsidian publish:site
obsidian publish:status
obsidian publish:add path="blog/my-post.md"
obsidian publish:add path="blog/my-post-assets/chart.png"
obsidian publish:remove path="blog/old-post.md"
obsidian publish:list
```
The explicit `path=` is important. The CLI defaults to the active file when no path is provided, and it also offers `publish:add changed`. I avoid the bulk form because my vault contains thousands of unpublished notes. The safe workflow is the same principle that motivated the custom script: name every file that is allowed to go live.
This ended up being a useful failure. The reverse-engineering was technically interesting, but the more important lesson is about tool discovery: before building against an undocumented protocol, verify the current official surface from primary documentation and distinguish an application's auto-updated code from its separately updated installer.
## Related
- [Obsidian as a Writing and Publishing System](./Obsidian%20as%20a%20Writing%20and%20Publishing%20System.md)
- [A Raspberry Pi Calendar for the Kitchen Wall](./A%20Raspberry%20Pi%20Calendar%20for%20the%20Kitchen%20Wall.md)