Guide · Product Engineering

Building Saphin AI: An AI Companion App

A look at the architecture behind Saphin AI, a mobile and web app that gives each user a warm, private AI companion. The interesting engineering problems weren't the chat itself — they were the decisions around it: how to keep the AI swappable, how to make the companion remember things without a vector database, and how to decide which features need a server push and which don't.

The shape of the system

The mobile app (Expo / React Native) and the web app (the same codebase, exported via Expo Web) never talk to the database or the AI provider directly — everything goes through a FastAPI backend. That single rule keeps secrets on the server, keeps business logic in one place, and means the client is, in a real sense, disposable: nothing about how memory works or how the AI is prompted lives in the app itself.

Postgres (via Supabase) stores everything relational — users, chat sessions, messages, and later, memories, moods, reminders, and goals — and Supabase Auth handles login so the project never has to touch a raw password.

Decision one: put the AI behind an interface, not a vendor SDK

Every part of the app that needs a reply calls an internal AIProvider interface — never a specific vendor's SDK. This paid for itself almost immediately: the first provider I tried required paid billing, the second returned a zero quota in my region, and the third worked. Because the chat code only ever spoke to the interface, switching providers was one new provider file plus one line in a factory function — nothing about sessions, memory, or the chat UI had to change. Any code that starts making decisions based on "which AI am I calling" is a sign the abstraction is leaking.

Auth: verifying tokens without a shared secret

Supabase signs auth tokens asymmetrically (ES256) and publishes its public keys at a JWKS endpoint, rather than using a static shared secret the backend checks against. The backend fetches and caches those public keys and verifies every request's token against them — with a small clock-skew allowance, since a backend server and an auth provider's clocks are never perfectly in sync, and a strict check will reject a freshly issued, perfectly valid token purely because it looks like it was "issued in the future" by a couple of seconds. The fetch itself also retries and, if the key server is briefly unreachable, keeps serving the last known keys rather than locking every user out — a slightly stale signing key is a better failure mode than a false "please log in again."

Memory without a vector database

A companion that forgets everything between messages doesn't feel like a companion. The memory system has two tiers, deliberately kept simple:

  • Short-term — the last stretch of the current conversation is fed back to the model as real message history, so it follows the thread naturally.
  • Long-term — after each turn, a best-effort background call asks the model to extract durable facts ("has a dog named Max") into a small table. Active facts are injected wholesale into the system prompt on every future message.

No embeddings, no retrieval step — per-user memory stays small enough to inject in full, which keeps it fast, free, and easy to reason about. It's a conscious trade-off: it doesn't scale to thousands of facts per user, but it's a clean upgrade path later (swap the injection step for a retrieval query) rather than a rewrite. Every later AI-adjacent feature — mood detection, personality tone, a "responding" journal, daily check-in messages — copies this same shape: a best-effort side-call that never breaks the main chat if it fails.

Proactivity: three features, three different mechanisms

The most useful lesson from this project was resisting the urge to solve "proactivity" with one generic system. Three proactive features shipped together, each using whichever mechanism actually suited it:

  • Daily check-in — a remote push notification. An external scheduler calls a secret-guarded backend endpoint on a short interval; the backend finds users whose chosen local time has arrived, writes a fresh one-line message with the AI, and sends it via push. This needs to be server-driven because the message itself is freshly generated.
  • Reminders — scheduled entirely on-device. A remote push would make reminders worse, not better: local scheduling fires at the exact second even if the server is asleep, with no network round-trip in the way. The reminder is mirrored to the backend only so it survives a reinstall and so the companion can reference it in conversation.
  • Goals — not a notification at all. Active goals are folded into the same memory context injected into the system prompt, so the companion can bring them up naturally without a single change to the AI provider's interface.

Matching the mechanism to the feature — instead of forcing everything through one "notifications system" — kept each piece simple and made the non-negotiable product rule easy to enforce: proactive messages are opt-in, on the user's own schedule, with no streaks or guilt-based nudging.

Attachments on a text-only model, without changing its interface

Adding voice, image, and document support could easily have meant a rewrite of the chat pipeline. Instead, each attachment type is converted to text before it ever reaches the AI: a voice note is transcribed and the transcript becomes the chat message; an image is described by a vision-capable model and the description is folded into the same context block used for memory; a document is parsed with plain text-extraction, no AI involved, and injected the same way. The result is that the core generate_reply function's signature never changed across the whole feature — attachments are just another source of text arriving through the same door as memory and goals.

One codebase, two platforms

The web version reuses the same React Native codebase via Expo Web rather than a separate frontend — the alternative was rebuilding every screen from scratch for marginal gain. Platform differences are handled with narrow, explicit checks rather than parallel code paths: biometric login and push-token registration are skipped on web since a browser doesn't need them; the mobile drawer becomes a permanent sidebar on wider screens; wallpaper images swap between portrait and landscape crops per platform. Each of these is a small conditional at the point of use, not a fork of the app — which is what makes a single codebase actually worth maintaining.

Where this connects to my work

Saphin AI is the largest project on my portfolio home, and it's where the abstraction-first habits from my data and automation work carried over most directly: an AI provider that can be swapped in one file, a memory system that injects context instead of hand-wiring prompts, and proactive features that each use the simplest mechanism that actually fits — the same instinct behind keeping FX-Insight's data sources and Reddit monitor's alert channels independently swappable.