Inspiration
Most "stay in touch" apps are retrospective. They wait until a birthday has already passed, or a contact has gone quiet for months, then nudge you with a guilt-shaped notification. By the time they speak up, you already knew.
We wanted something that noticed earlier. While a friendship was still in the process of fading, not after.
And once we started thinking that way, a more interesting question showed up on its own: if you can notice endings quietly, you can notice beginnings quietly too. A new person becoming part of your week, before it feels significant enough to mention out loud. That symmetry became the actual idea.
One rule we set from the start, and never broke:
- No scores.
- No rankings.
- No advice telling you what to do.
The moment an app tells you what to do about your friendships, it stops being a quiet observer and becomes one more notification demanding something of you. We wanted it to feel like a friend noticing something out loud, never a system judging you.
What it does
In Touch tracks contact rhythm across relationships and watches for sustained change against each person's own history, never a universal average. When a shift holds long enough to be real rather than noise, it surfaces exactly one honest, specific sentence. Nothing before that moment. Nothing after.
It ships with six illustrative synthetic relationships to demonstrate the full range of what it notices — a fading friendship, a forming one, a brand-new fast connection, and a steady control case. But it isn't only a demo:
- Import a real WhatsApp chat export, entirely on-device — the app derives real monthly texting frequency and never reads, stores, or transmits a single message.
- On Android, scan your synced calendar for real meetup frequency with someone, using only an on-demand permission request — no titles, guest lists, or event details ever leave the device, only monthly counts.
- On Android, those two real sources can combine for one person into a single richer pattern when their histories overlap.
A brand new connection needed genuinely different logic from an existing one — there's no personal history yet to measure against, so cold-start detection compares against a reference built from this user's own established relationships, not a fixed universal number.
How we built it
I built this with Codex, running on GPT-5.6, as my engineering partner, but every decision was mine: the detection math and its weights, the cold-start logic, where the privacy line sits for WhatsApp and calendar imports, the entire visual system, and what the app would and wouldn't say to a user. I stayed hands-on throughout, reviewing every piece of output, catching real bugs myself (including a cold-start calculation that was quietly inverted, and a Reports-tab suggestion naming the wrong signal), and testing everything end-to-end: real WhatsApp exports, a live calendar scan, the signed APK on my own phone.
The architecture stays split into two halves that never blur:
- Deterministic core — a four-month personal baseline per contact, per signal, normalized independently and combined with fixed, explainable weights. A flag only fires once a shift crosses threshold and holds for two consecutive months. No LLM touches this layer — it's math we can defend line by line.
- A narrow LLM layer — only once something has genuinely fired does a model get involved, with one job: turn a structured result into one well-phrased sentence, guarded against advice, scoring language, or anything longer than a single line, with a deterministic fallback if it ever fails twice.
That same discipline extended to every real-data feature we added afterward. WhatsApp parsing discards message content immediately after checking for it. Calendar scanning reads only what's needed to match an attendee, then keeps nothing but a monthly count. Neither the deployed web app nor the Android app calls an external API for anything a user does. Gemini is used once, offline, only to write the demo's illustrative sentences.
Our sense of what this needed to be changed more than once. It started as something deliberately quiet, a paper-and-ink 'field notebook' feel, almost like a private journal, because the idea itself is about noticing something subtle. But a demo that quiet risked disappearing entirely in a room full of other projects, so we rethought it: it needed to feel like something people could actually hold and use, not just read about — which is what pushed it toward the dark, jewel-toned phone experience it has now. And once that shift happened, the same question kept coming back: why stop at a demo that only lives in a browser tab? That's what led to the Android app, not a wrapped webpage, but a real, full-screen, installable experience with on-device persistence, offline operation, and a light or dark feel chosen once and remembered.
Challenges we ran into
The cold-start percentage bug. Our first version compared a new contact's growth against a fixed, universal reference — which meant a rapidly forming friendship could read as a decline, simply because it hadn't caught up to "normal" yet.
The same bug, one layer deeper. Fixing the display math wasn't enough — the reference itself was still a single hardcoded number, which meant it couldn't be right for both someone who texts close friends 15 times a week and someone who texts 500. The real fix was making the reference personal: derived from the user's own established relationships, not a constant baked into the code.
Android's real permission walls. We originally hoped to detect changes in call frequency too. Android restricts READ_CALL_LOG to default-dialer and default-SMS apps by design. No sideloaded app can reliably get it, hackathon or otherwise. Rather than fight the platform, we treated it as a real design constraint and built the calendar-based meetup detector instead: a genuine, permission-gated, on-device signal that answers the same underlying need honestly.
Building for a platform I'd never shipped to. Packaging a real Android app was new ground entirely. Signing keys, permission scoping, and making something that worked identically to the web demo but felt like a real installed app, not a browser tab pretending to be one. The hardest part wasn't the code, it was the platform's own limits: Android restricts call-log access to default-dialer apps by design, which meant a feature I'd originally planned simply wasn't buildable, no matter how it was written. Rather than force it, I had to actually sit with that constraint and find the honest alternative, a real, permission-gated calendar scan. That answered the same need without fighting the platform.
Accomplishments that we're proud of
- A detection engine that's actually explainable, on both synthetic and now real, on-device data, every flag traces back to specific numbers, specific months, and a specific rule.
- Three separate real-data import paths, one consistent privacy discipline — WhatsApp text content, calendar event details, and message bodies are all handled the same honest way: read just enough to count, then discarded.
- A real installable Android app, not a wrapped demo — offline, persistent, with genuine device permissions requested only when the user asks for them.
- Knowing when to say no — call-log integration was a real idea we deliberately didn't force past a platform's own limits.
What we learned
Restraint is a design decision, not a missing feature — and it applies to what data you ask for, not just what the UI shows. The instinct throughout was to add more signals, more screens, more permissions. Almost every time, the better version asked for less, and was honest about exactly what it couldn't see.
What's next for In Touch
-Real-time notifications the moment a signal actually crosses threshold, instead of only on request -Live WhatsApp and calendar syncing, so detection updates continuously instead of relying on a one-time import -Real Contacts integration, so tracked people don't need to be added by hand -More sources of signal — Discord, Telegram, and beyond WhatsApp and calendar alone -Play Store distribution, moving past direct APK sideloading
The core promise stays the same as it's always been: it reads patterns, never content.
Built With
- codex
- css
- framer-motion
- fraunces
- gemini-3.5-flash
- gemini-api
- git
- github
- google-fonts
- google-genai
- gpt-5.6
- javascript
- json
- pnpm
- python
- react
- svg
- typescript
- unittest
- vercel
- vite
Log in or sign up for Devpost to join the conversation.