Most "AI productivity" apps have the same architecture: your data lives on their server, the assistant lives next to it, and privacy is a paragraph in the terms. I wanted the opposite for Ome. Tasks, habits, notes, calendar and focus timers stay on the phone, sync is optional and end-to-end encrypted, and the assistant still knows what you have to do today.
Those two goals pull against each other. An assistant is only useful if it has context, and context is exactly the data I did not want leaving the device. This post is how I resolved that, in enough detail to copy.
The rule
One sentence drives the design: the model sees a summary the user could read, built on the device, for one request, and nothing is stored on the server afterwards.
Every part of that sentence removed a feature I would otherwise have built.
- "a summary the user could read" ruled out sending raw notes. The app shows the exact context it is about to send, and the user can collapse any section out of it.
- "built on the device" ruled out a server that holds a copy of the data to be helpful.
- "for one request" ruled out long-lived memory on the server. Memory lives in the app's local database, encrypted with the rest.
- "nothing is stored afterwards" ruled out logging prompts for later analysis, which hurt, because that is how you improve prompts. I improve them from my own usage and from bug reports people choose to send.
Building context on the phone
The assistant does not get a dump of the database. It gets a context block assembled by a small set of selectors, each answering one question the assistant is likely to need.
A selector is a plain function over the local database with a hard cap on what it returns:
abstract class ContextSelector {
String get title; // shown to the user as a section heading
Future<List<String>> lines(); // at most `cap` short lines
int get cap => 12;
}
class DueTodaySelector extends ContextSelector {
final String title = 'Due today';
Future<List<String>> lines() async {
final tasks = await db.tasks.dueOn(today(), limit: cap);
return tasks.map((t) => '- ${t.title} (${t.priority.label}${t.estimate != null ? ', ~${t.estimate}m' : ''})').toList();
}
}
The selectors that exist: due today, overdue, habits with a streak at risk, the next three calendar events, the note currently open if there is one, and the last few assistant actions so it does not repeat itself. Each contributes a short list. Together they are rarely more than 600 tokens, which keeps requests fast and cheap and, more importantly, keeps the context small enough that a person can skim it before sending.
Notes are the one place I was strict. The assistant never receives a note the user has not opened. If you want help with a note, you open it and ask. Everything else about your notes stays where it is.
The reply is actions, not prose
An assistant that only answers in prose makes the user do the data entry. Ome's assistant answers in two parts: a short message, and a list of actions in a fixed JSON shape.
{
"message": "Three things are due today and one habit streak ends tonight. I split the report into steps.",
"actions": [
{ "type": "task.create", "title": "Draft intro for report", "due": "today", "estimate": 25 },
{ "type": "task.create", "title": "Collect figures for section 2", "due": "today", "estimate": 40 },
{ "type": "habit.remind", "habitId": "h_reading", "at": "20:30" }
]
}
The app validates every action against a schema on the device, applies the safe ones immediately, and asks before anything destructive. There is no task.delete in the vocabulary at all. If the assistant wants something gone it can propose task.complete or task.archive, both reversible. The model cannot do anything the schema does not list, which is the only kind of guardrail I trust.
Structured output also fixed the thing that made early versions feel flaky: the assistant said it had created a task and had not. When creation is an action the app performs, the message and the result cannot disagree.
Sync and encryption, briefly
Local-first means the phone is the source of truth. Sync is a change log of encrypted blobs. The key is derived from the user's passphrase and never leaves the device, so the sync service stores data it cannot read. If someone loses their passphrase, their synced copy is gone; the app says so when you turn sync on, in plain words, because I would rather lose a few sign-ups than hold a key.
The assistant pipeline sits entirely on the decrypted side. Nothing about the AI feature knows that encryption exists, which is how I wanted it.
What it cost
Local-first with a capable assistant is slower to build than cloud-first. Roughly a third more time went into the sync layer and into the context selectors than a server-side version would have needed. It also means some features are harder: "search across everything I have ever written" is a fine feature when the server has an index, and a careful one when it has to run on a phone.
What it bought is a sentence I can put on the store listing without a lawyer: your data stays on your device unless you decide otherwise. For a productivity app, where the data is the most personal thing people own, that sentence has been worth the third.
Ome is on the App Store and Google Play.
