Capture a contact in seconds. The cloud does the rest.
A tiny Android app for personal relationship management. You meet someone, you tap the Quick Settings tile, you jot a name and a voice note, and you hit Save & enrich. A Next.js backend on Vercel transcribes your voice memo, finds the person on LinkedIn, grabs their profile picture, resolves the company domain, and writes a clean summary. The app shows you everything it found; you review and confirm (editing anything that's off), and only then is the person filed in Twenty and Google Contacts. If you are offline, capture queues and enrichment runs as soon as you reconnect.
- One tap capture from the Quick Settings tile, no need to open the app.
- Voice notes recorded inline and sent with the contact.
- Events and sources you define once and reuse for fast tagging.
- Live LinkedIn re-fetch. Edit the LinkedIn URL on the review screen and the photo + headline re-resolve for that exact profile (or clear, if it can't be found).
- Offline first. Contacts are stored locally (Room) and sent by a WorkManager job that retries on failure.
- Material You. Dynamic color on Android 12+, with a branded fallback palette below it.
┌──────────────── POST /api/enrich (multipart) ───────────────┐
Android app ───────►│ Vercel backend (Next.js) │
(Compose) │ transcribe (Groq) · LinkedIn + avatar + domain (Serper) │
▲ │ summarize (Kimi K2.6 / OpenRouter) — no writes │
│ enriched └─────────────────────────────────────────────────────────────┘
│ data
Review & confirm (edit anything)
│
└──────────────► POST /api/commit ──► Twenty CRM person + Google Contact
(idempotent on the stable PRM ID, stored in both)
The flow is two steps:
-
Enrich (
POST /api/enrich,multipart/form-data) — read-only. The app sends the captured fields + optional voice file; the backend returns what it found, without writing anywhere:firstName, lastName, company, number, note, events, sources, clientId, createdAt, voice (audio/mp4){ "prmId": "…", "transcript": "…", "linkedinUrl": "https://linkedin.com/in/…", "headline": "Founder at …", "avatarUrl": "https://…", "companyDomain": "acme.com", "summary": "Met at the founder dinner, building in fintech.", "enriched": ["LINKEDIN", "JOB_TITLE", "AVATAR", "COMPANY"] } -
Commit (
POST /api/commit,application/json) — after you confirm in the review screen, the backend upserts the Twenty person and creates the Google contact, then returns the IDs:{ "prmId": "…", "twentyId": "…", "twentyUrl": "https://crm…/object/person/…", "googleResourceName": "people/c…" }
Between the two, editing the LinkedIn URL in the review screen calls a small read-only helper that re-derives the photo and headline for that profile (debounced; blank when it can't be resolved):
- LinkedIn lookup (
POST /api/linkedin,application/json) —{ "url": "…", "name": "…" }→{ "linkedinUrl": "…", "headline": "…", "avatarUrl": "…" }.
Every contact carries a prmId (a UUID generated on capture). It is written to both systems —
a prmId custom field on the Twenty person, and a PRM ID userDefined field on the Google
contact (visible as a custom field in the Google Contacts UI). Because the identifier is independent
of the name and notes, the person stays identifiable after you rename or edit them — and commit is
idempotent: re-running it finds the existing Twenty person by prmId and updates rather than
duplicating.
A stateless Next.js (App Router) API under backend/, deployed to Vercel. It has no
database — the Android app (Room) is the source of truth and carries the enrichment between the two
calls. Phone numbers are split into Twenty's required parts (national number, calling code, ISO
country) before they're sent.
It needs these environment variables (see backend/.env.example):
| Variable | Purpose |
|---|---|
APP_API_SECRET |
Shared bearer token the app sends; gates every endpoint |
GROQ_API_KEY |
Voice transcription (whisper-large-v3) |
SERPER_API_KEY |
LinkedIn URL + avatar (/images) + company domain (knowledge graph) + per-profile re-fetch |
OPENROUTER_API_KEY |
Note summary; model OPENROUTER_MODEL defaults to moonshotai/kimi-k2.6 |
TWENTY_API_KEY, TWENTY_BASE_URL |
Twenty CRM REST API |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN |
Google People API |
The Twenty Person object needs two custom fields:
prmId· Type: Text — the stable identifier (created automatically if you use the bundled tooling).Enriched· Type: Multi-Select · valuesPHONE,JOB_TITLE,CITY,COMPANY,AVATAR.
The previous n8n workflow is kept for reference but is deprecated — the Next.js backend replaces it.
Backend (Vercel):
cd backend && pnpm install.- Create an OAuth 2.0 Desktop app client in Google Cloud, enable the People API, then run
GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… pnpm google-authand copy the printed refresh token. - Import
backend/as a Vercel project (set Root Directory tobackend) and add the env vars above. - Ensure the Twenty
prmId(Text) andEnriched(Multi-Select) fields exist on Person.
App:
- Install the APK, open Settings, paste the API base URL (your Vercel URL) and the API
secret token (
APP_API_SECRET), and add your events and sources.
# Android app
./gradlew :app:assembleDebug
# output: app/build/outputs/apk/debug/app-debug.apk
# Backend
cd backend && pnpm install && pnpm buildApp requires JDK 17 and the Android SDK (compileSdk 34, minSdk 26). Backend requires Node 18+.



