Why we read chat.db locally: privacy as architecture, not promise
Apple Full Disk Access, direct chat.db reads, ephemeral processing. Why local-first beats privacy promises, with the architecture diagram in plain ASCII.
Apple does not give you an API for iMessage. There is no imessage.com/v1/messages endpoint. There is no OAuth flow that grants you read access to a user’s iMessage thread. There is no SDK. The only path is a SQLite database called chat.db, sitting on the user’s own Mac at ~/Library/Messages/chat.db, accessible only after the user grants Full Disk Access to your application through the System Settings privacy pane.
When we started building the iMessage connector, the obvious-sounding architecture was: forward the messages to our backend, process them in the cloud, draft replies on our servers, push back to the device. That’s how every cloud-first AI product works. It’s how Gmail integrations work. It’s how Slack integrations work.
We didn’t build it that way. We built it the opposite way. The chat.db read happens on the user’s machine. The processing happens on the user’s machine. Only the drafted reply, after the user approves it, leaves the device, and only when there’s a card to render in the feed.
This post is about why. Not the marketing-pitch why (“we care about your privacy”), the architectural why. Local-first isn’t a promise. It’s a property of the system you can verify by inspecting what crosses the network boundary. I want to show you what that boundary looks like.
The architecture, in plain ASCII
+--------------------------------------------------+
| user's macOS device |
| |
| ~/Library/Messages/chat.db (Apple's SQLite) |
| | |
| | mac-agent reads (FDA grant) |
| v |
| +-----------------------------+ |
| | Krewva mac-agent process | |
| | (spawned by Electron app) | |
| | | |
| | - polls chat.db for new | |
| | messages by ROWID cursor | |
| | - extracts thread context | |
| | - calls draft generator | |
| | (DeepSeek API, network) | |
| +-------------+---------------+ |
| | |
| | only the drafted reply + |
| | the inbound message metadata |
| | crosses the boundary |
+----------------+---------------------------------+
|
v
+--------------------------------------------------+
| Krewva backend (EC2) |
| |
| - persists card metadata only |
| - stores draft text for user review |
| - never holds raw chat.db rows |
| - never holds full thread history |
+--------------------------------------------------+
The crucial property is: chat.db never leaves the device. The full thread history never leaves the device. The contact graph never leaves the device. What does leave the device is the minimum information needed to render a card, the inbound message that triggered the draft, the contact display name, and the drafted reply. That’s it.
This is what local-first means in code, not in copy.
The cloud-first version we didn’t build
The temptation to build cloud-first was real. Cloud-first is easier in every dimension that matters for engineering velocity:
- Easier to debug. All the logs are in one place. CloudWatch shows you everything.
- Easier to scale. Add an instance, drain the queue faster.
- Easier to ship features. New AI behavior? Deploy to one server.
- Easier to do ML. All the training data is already centralized.
The local-first version is the opposite of all of those. The mac-agent runs on the user’s hardware, on whatever macOS version they have, with whatever battery state, whatever sandbox quirks the current OS imposed last Tuesday. It’s a million-process distributed system where every node is configured slightly differently. It’s a hellish surface to debug.
But the cloud-first version has one architectural property that disqualifies it for iMessage: once chat.db rows leave the device, the privacy promise is a contract, not an architecture. The user has to trust us not to log them, not to retain them, not to use them for training, not to leak them in a breach. That trust is fine in a vacuum. It is not fine when the data in question is twelve years of conversations with your spouse, your kids, your friends, your therapist.
Every cloud-first messaging product is exactly one rogue engineer or one breach away from being a privacy disaster. We didn’t want to be that company. So we picked the harder architecture.
What the FDA grant actually does
Full Disk Access is the macOS privacy primitive that lets a third-party application read protected paths, including ~/Library/Messages/chat.db. The user grants it through System Settings → Privacy & Security → Full Disk Access, and the grant is per-application, persistent across reboots, revocable at any time.
When we ship the Krewva macOS app, the user is walked through this grant explicitly during onboarding. The screen at macos/src/renderer/src/screens/ConnectIMessage.tsx shows the exact System Settings panel they need to open, the toggle they need to flip, and an explanation of what the access is for. After flipping the toggle, the user clicks “Restart Krewva” and the mac-agent starts reading.
Until that grant happens, no read happens. The grant is the user’s affirmative consent, baked into the OS. We can’t bypass it. We don’t want to.
(There’s a side benefit here: the user can revoke FDA at any time and the system stops reading. They don’t have to call us. They don’t have to log into our app. They flip the toggle and we’re locked out within seconds. That’s the kind of control that’s only possible because the trust boundary is the OS, not our backend.)
The mac-agent process
The mac-agent is a separate Node.js process spawned by the Electron app at macos/src/main/agent-supervisor.ts. It runs as the user, not as root. It has a state file at ~/Library/Application Support/KrewvaMacAgent/ that tracks its cursor position in chat.db (the last ROWID it has seen). It polls every few seconds for new messages.
When it finds a new message, it does something most cloud-first products can’t do: it has access to the entire local thread history for context selection. It can pull the last fifty messages from the same conversation, extract the contact’s display name from the device’s address book, and feed all of that into the draft generator, without any of it ever crossing the network. The model that drafts the reply (DeepSeek, called from the mac-agent) sees the inbound message plus a recency-weighted slice of thread history. The full thread stays on the device.
The drafted reply, plus the metadata needed to render a card (timestamps, contact name, conversation ID), gets posted to our API. That’s the boundary crossing. We persist a card row, send a realtime envelope to the user’s other devices, and the card appears in the feed. The user approves or denies. If approved, the mac-agent receives an agent_command to send the reply through Apple’s Messages.app (via AppleScript, the only path Apple allows for sending). The reply goes out from the device.
At no point does the chat.db file leave the device. At no point does our backend hold the full thread.
The compromises we made
I want to be honest about what local-first costs.
Cost one, onboarding friction. The FDA grant is a multi-step process the user has to complete inside System Settings. We can’t streamline it; Apple deliberately made it slow to prevent malicious apps from sneaking through. Our onboarding has a higher drop-off at this step than any other. We accept it because the alternative is asking the user to trust our backend, and we’d rather they trust their OS.
Cost two, multi-device complexity. If the user has two Macs (a work laptop and a home laptop), iMessage works across both, but our mac-agent only runs on whichever machine has the app installed. We handle this with a “primary device” concept and a clear UI signal showing which device is currently the source of truth. It’s not as clean as cloud-first (which would just work everywhere), but it’s the trade-off we chose.
Cost three, slower feature shipping. Every new feature that touches iMessage has to ship through the macOS app, which means an Electron release cycle, a code-signed build, a notarization step, and the user actually updating. We can’t push a backend change and have it go live for everyone overnight. This slows us down. Again, accepted.
The version of “privacy” we believe in
The word “privacy” is overloaded. Most companies use it to mean “we will treat your data carefully.” That’s a promise. It’s enforced by their hiring decisions, their internal access controls, their breach-detection systems. It can be broken at any time by a single point of failure.
We use “privacy” to mean the data isn’t there to be misused. The chat.db rows don’t sit on our servers, so we cannot leak them. The thread history isn’t in our database, so we cannot accidentally use it for training. The contact graph never crosses the boundary, so we cannot sell it to advertisers (which we wouldn’t anyway, but the architectural property is what matters, not our intent).
This is the version of privacy that survives a breach. If our backend is fully compromised tomorrow, the worst-case loss is the metadata for cards already created, drafts, approvals, denials. The actual conversations remain intact on user devices, untouched by the breach.
That’s not a marketing slogan. That’s a property you can verify by reading our code and watching the network traffic.
Closing
When you’re picking the architecture for an AI product that handles sensitive data, ask yourself this: if my backend gets fully compromised, what’s the worst case? If the answer is “the user’s entire conversation history leaks,” you’ve built the wrong shape. If the answer is “we lose some metadata and the user has to reconnect,” you’ve built local-first, even if you didn’t call it that.
Privacy as architecture, not promise. That’s the line we keep coming back to. It costs us in onboarding friction and feature velocity. We pay it because we don’t want to be the company that has to apologize for a breach we made structurally possible.
— Haiyang Wu, CTO of Wuvov
Quarterly notes from the build.
We send a short email when we ship something we're proud of. No growth-hacker tricks, no spam — just notes from the founders.