Local-First AI Contacts Assistant: Bringing the LLM into Your Address Book Without Letting Data Leave Home
Chapter 1: The Pain Point — What Contacts Lack Isn't Storage, But "Context"
Everyone carries an address book in their phone: hundreds of names, numbers, and locations. It helps you dial and text, but not the more everyday task — knowing how to open a conversation with someone.
- An old colleague you haven't spoken to in a while — what tone is right for a holiday greeting?
- A client saved only by name — how do you address them without awkwardness when reaching out?
- A long-lost friend you've been meaning to gather with pops into your head, but you can't recall their name.
This information is scattered across memory, notes, and conversations; the address book itself carries none of it. So I built a local-first tool: read the address book on-device and layer on a privacy-conscious AI assistant — to help draft messages, understand relationship dynamics, keep private notes, and look people up in plain language. The key constraint was set on day one: the address book is highly sensitive data, and it must never leave the device.
Chapter 2: Architecture — What "Local-First" Actually Means
"Local-first" is not a slogan; it is implemented in three concrete designs.
1. Data never leaves the device
The address book is read on-device via expo-contacts; all contact information exists only in runtime memory and local storage, and is never uploaded to any server.
2. Minimal context sent to the model
The AI features call an OpenAI-compatible LLM endpoint, but only send the minimum information necessary to complete the current task — typically "contact name + phone-number location (city name) + your intent/scenario." The prompt does not include fields that directly identify a person, such as phone number or email, nor the entire address book. The model returns suggested text, not any inference about the contact's identity.
3. Keys go into system secure storage
The LLM API key is not written into the JS bundle or stored in plaintext fields; it is kept in the system's secure storage (Keychain / Keystore equivalent) and retrieved by the native layer only at call time. Even if someone decompiles the package, they cannot obtain a usable key.
Local state (notes, profiles, drafts) is persisted with AsyncStorage, isolated by contact ID, retained across sessions, and clearable at any time.
Chapter 3: AI Draft — Never Get Stuck on the Opening Line
"✍️ AI Draft" is a universal message composer decoupled from any private data (see src/utils/contactComposer.ts). Its prompt engineering has a few deliberate designs:
- Use the given name, not the full name: For a contact like "Zhang Wei," the model is instructed to prefer the given name or a surname-dropped address ("Wei"), avoiding the stiff full-name call; when seniority is unclear, use a neutral address rather than defaulting to "bro/sis."
- Scenario-based tone: Built-in intent tags such as "small talk / invitation / greeting / apology / business" give different tonal examples per scenario.
- Signature from "About Me": After defining "how you'd like to be addressed" (e.g., "Lao Wang") and gender in Settings, the model prefers this setting for self-reference or sign-off instead of inventing one.
A realistic example: intent=invitation, scenario=friend, self-name="Lao Wang" — the model might give:
Wei, how have you been? Free to grab a meal together next weekend? — Lao Wang
Drafts are stored locally by contact ID, editable, regenerable, copyable, and clearable.
Chapter 4: AI Persona — How to Deal with This Person
"💡 AI Persona" generates three sections of advice (see src/utils/contactInsight.ts):
- Relationship positioning: Place the other person in a relationship context (old colleague, long-lost friend, business counterpart…).
- Communication boundaries: What tone to use this time, what boundaries to observe.
- Icebreaker example: A directly usable opening line.
It only looks at the few signals you provide: contact name, location, your relationship/addressing preference, and the "About Me" setting. It does not read or guess the other person's specific identity or past. The gender setting in "About Me" tunes the communication boundaries; if "prefer not to say" is chosen, it falls back to neutral advice. Results are stored locally by contact ID and overwritable.
Chapter 5: Local Notes + Natural-Language Search
📝 Local Notes (src/utils/contactNotes.ts): one private note per contact, stored only on-device, never entering any model and never uploaded.
🔍 Natural-Language Search (src/utils/contactSearch.ts): when you don't want to scroll the list, just query in plain language — for example, "notes mentioned a fellow townsman who once worked at the same company." The model interprets this as a filter intent and matches within the scope of name / location / notes, returning the matching contact IDs. Likewise, it retrieves only on these non-sensitive fields and never touches private information such as phone numbers.
Chapter 6: Export and Privacy Boundaries
The app supports two kinds of export:
- CSV (
src/utils/exportCsv.ts): name, phone number, location, email, notes — standard address-book fields. - Markdown profile (
src/utils/exportProfile.ts): builds on the CSV by appending each person's AI persona and drafts into a readable personal-relationship profile, exportable via the system share sheet.
The exported fields contain only what you actively entered or the app generated (name / phone / email / notes / persona / drafts), with no model-internal state or external derived data.
To close the privacy boundary: all contact data stays on the device; AI features send only the minimum context needed for the current task; the API key resides in system secure storage. This is the three-fold guarantee that "local-first" promises.
Source Code Navigation
App.tsx– Tab navigation architecture, entry point for the three major modules: Contacts / Chat / Settingssrc/screens/ContactsScreen.tsx– Local contacts reading and list main pagesrc/screens/SettingsScreen.tsx– Provider presets, API key secure storage, About Mesrc/utils/contactComposer.ts– AI message composer (prompt engineering: given-name address / scenario tone / sign-off)src/utils/contactInsight.ts– AI persona (relationship positioning / communication boundaries / icebreaker)src/utils/contactNotes.ts– Local notes read/writesrc/utils/contactSearch.ts– Natural-language search intent understandingsrc/utils/exportCsv.ts/src/utils/exportProfile.ts– CSV and Markdown profile exportsrc/config/providers.ts– LLM service provider preset configuration (OpenAI-compatible)
Links
- Project repository: https://github.com/erishen/contacts-assistant