notemd · 2026-07-17 · 12 min read
A folder is my API: giving an AI agent write access to a sandboxed Mac app
A few weeks ago I let Claude write into my vault, the articles half of notemd, where every note is a plain .md file on disk. That one turned out to need three layers of sync work, but the interface was free. Claude has a filesystem connector, the notes are files, done.
This post is about the other half, and it did not get to be that lazy.
notemd has two kinds of content. Articles are the disk-mirrored half. Knowledge sources, the PDFs and web links and datasets, the citation metadata, the extracted text, the page anchors, are the Core Data half. They're not files you can hand an agent. They're managed objects behind a sandbox, a merge pipeline, and an extraction engine. And I wanted the same thing for them that I'd gotten for articles: point Claude at notemd and say "tag every source about caching, pull the references out of that paper as text, delete the duplicate," and have it actually happen.
The whole design turns on one question I couldn't wave away: who is allowed to write?
The shape of it
The one-sentence version: reads are app-independent, writes are app-mediated, and the wire between them is a folder.
Why there's exactly one writer
I already had scars here. notemd's Core Data store is not a database you can just poke from two processes. Earlier this year I gave myself a genuinely nasty bug, a duplicate Article id that could crash the merge and the graph, by having two contexts touch the same objects without coordinating. The vault merge only survives because it's careful to be the single writer reconciling disk into Core Data.
So a design where the CLI reaches into the store directly was a non-starter. A headless CLI process opening the same .sqlite, running its own migrations, writing its own objects, while the app is open doing the same. That's the duplicate-id hazard with a bigger blast radius. The safe move is boring. the app is the only thing that ever writes. The CLI never mutates anything. It asks.
That splits the whole surface cleanly in two, and the split is the architecture:
- Reads don't need the app at all. The app periodically denormalizes each project's sources into a plain JSON read-model, and the CLI reads that plus the files on disk. You can search, walk the graph, pull source text, and export
.bibwith notemd closed. - Writes must go through the app, because the app is the writer. If it's not running, a write can't happen, and the CLI has to say so immediately, not hang.
The channel is just a shared folder
I didn't want a socket, a port, an XPC service, or a daemon. The app is sandboxed and ships on the Mac App Store; the fewer moving parts touching entitlements, the better. What both processes can already share is an App Group container, group.com.arsoftware.notemd, a folder both the app and the CLI can read and write by identity, with no server required.
So the folder became the protocol. It's a message queue implemented in files:
commands/inbox/<commandID>.json, where the CLI drops a write command.commands/results/<commandID>.json, where the app writes the outcome back.commands/jobs/<jobID>.json, for anything async (a delete that has to drain in-flight extraction first).read-models/<projectID>/sources.json, the app's denormalized view of every source.heartbeat.json, where the app says "I'm alive" on a timer.staging/, where the CLI drops PDF bytes, because a sandboxed app can't read some arbitrary path the agent names.
The app side is a tiny poll loop. AICommandInbox wakes every second, reads each command file, hands it to AICommandHandler, and writes the result. Idempotency is free and file-shaped: if a result file already exists, the command already ran, so a restart mid-drain never double-applies. A corrupt or unreadable command file gets discarded rather than poisoning the loop forever. It's a queue, but I never had to run a queue.
The command envelope is deliberately generic, { op, projectID, args }, so adding the eleventh or twelfth operation is not a protocol change. It's a case in the handler and a catalog entry in the CLI. That was the goal. Make new capabilities additive, so "full parity" is a to-do list, not a redesign. Twelve write ops later (tags, metadata, references, move, add-PDF, folder CRUD, anchor CRUD, delete) the wire format hasn't changed once.
Fail-fast is a feature, not an error path
The rule I gave myself early: it's fine if the app has to be open for writes, but it is never fine for the CLI to hang waiting.
That's what heartbeat.json is for. The app's AIWriteBridge writes a fresh timestamp on a timer and holds a beginActivity assertion so App Nap doesn't quietly suspend the whole thing in the background. Before the CLI enqueues a write, it reads the heartbeat. Stale or missing means "app isn't there," and the CLI returns a clear message instead of parking on a result file that will never appear. A write to a closed app fails in milliseconds with a sentence a human, or an agent, can act on.
There's a subtle App Nap trap worth calling out, because it's exactly the kind of thing that works on your desk and dies in the field: the merge/watch machinery from the vault post gets torn down when the app backgrounds. If the only thing keeping writes flowing were that machinery, the bridge would go deaf the moment you ⌘-Tabbed away. So the inbox loop and the activity assertion are their own always-on thing, independent of window state.
The part I was smart enough not to build
The nicest architectural decision on this whole project was a deletion.
I had a plan drafted for an articles write-bridge, the same inbox/handler machinery extended to notes. Then I stopped and remembered that articles are already .md files on disk, and Claude already has a filesystem connector, and the vault merge already adopts a plain-Markdown file and assigns it an id. An agent doesn't need my queue to create a note. It writes # Title and a body and some [[wikilinks]], and the watcher-and-merge from last month picks it up as if a human saved it in another editor.
So the articles half of "full parity" cost zero code. The bridge only exists for the content that genuinely can't be a file. Knowing which half is which was the actual design work. The rest is plumbing.
What live testing found that review didn't
Here's the reflective bit, and the reason I don't trust a write bridge until I've watched it delete something for real.
The design reviewed clean. The unit tests were green. And then I ran the delete path end-to-end against a running app, and it hung. The command went into the inbox, and nothing came back. Ever.
The bug was a reentrancy I'd reasoned right past. delete_source is async: removePDF has to drain in-flight extraction before it can remove the row, so it does its work on a background context via performAndWait. My inbox loop, trying to be helpful, immediately refreshed the read-model on the same context the delete was still draining on. Two performAndWaits converging on one context is a deadlock, and the whole inbox seized behind it. The fix was to stop being eager: synchronous ops refresh right away, async ops refresh only when their completion monitor fires, and always on a fresh context. Nothing in the static design said "these two will collide". You only see it when the timing is real.
Then, with the deadlock gone, a quieter one. The delete now worked, and relaunching the app showed the source genuinely gone, but during the session the read-model kept listing it. The store said deleted; the JSON said present. That contradiction is a stale to-many relationship cache: I was rebuilding the read-model by walking project.knowledgePDFs, and Core Data hadn't invalidated that cached relationship when the row was deleted on a different context, even though a direct fetch of the same object by id correctly returned nil. The fix is to not trust the relationship traversal for freshness: fetch the sources directly, keyed on the inverse, so it always reads current store state.
// A to-many relationship cache can go stale when a source is deleted on a
// different context; a direct fetch keyed on the inverse always reads current state.
let request: NSFetchRequest<KnowledgeSource> = KnowledgeSource.fetchRequest()
request.predicate = NSPredicate(format: "project == %@", project)
return (try? context.fetch(request)) ?? []Both bugs live in the seams, at the boundary between the async service, the inbox loop, and the read-model writer. None of them is visible in any single file. The lesson I keep re-learning is that a message-passing design moves your hardest bugs out of the functions and into the timing between them, and the only tool that finds those is running the real thing and watching.
What I deliberately left out
There's no per-agent write token, and that's a decision, not an omission. The threat model is same-user, same-machine. Any process that can read the App Group container can already read and write it, so a token in that container guards nothing a determined local process couldn't route around. The real gates are the ones that mean something: the MCP host's per-tool approval, the app's Premium check in the handler, and an .aiignore refusal so an agent can't exfiltrate a source you've marked off-limits. If I ever ship this to a multi-user or remote context, the token goes in. Until then it'd be security theater with a rotation UI.
Did it work?
| Approach | Status | |
|---|---|---|
Read sources / graph / text / .bib | App-independent, off the read-model + files | app can be closed |
| Write sources (12 ops) | App-mediated queue, single writer | app must be open, fails fast if not |
| App closed during a write | heartbeat check | clear error in ms, never hangs |
| Notes / articles | filesystem connector + existing merge | zero bridge code |
| Concurrency safety | one writer, ever | no duplicate-id hazard |
| Adding an op | case + catalog entry | no protocol change |
The end state is the thing I wanted. I can hand an agent the run of notemd, reading anything with the app shut, mutating sources through a queue when it's open, editing notes as files, and the worst it can do is be told "the app isn't running." A folder turned out to be enough of an API.
Until next week, Andre