Mobile · AI Agent

Sable

Local-First AI Financial Agent

React Native (Expo)TypeScriptSQLiteZustandOpenAI · Function CallingLocal RAG
SQLiteon-deviceOpenAIfn-callpropose(intent)Review & ConfirmConfirm→ commit

01 · Executive Summary

Personal-finance apps ask for the most sensitive data a person owns — every transaction, balance, and counterparty — and almost always ship it to a cloud backend to do anything intelligent with it. That trade is exactly why most people never connect their real accounts.

Sable inverts it. Every row of financial data lives in an on-device SQLite store — there is no cloud backend and no account. On top of that local store sits an OpenAI function-calling agent that categorizes spend, tracks peer-to-peer debts, and delivers proactive briefings — while a hard UI boundary ensures a probabilistic model can never silently mutate the ledger.

Who it's for: people who want an AI that actually reasons over their finances without surrendering the data to anyone — privacy as an architectural guarantee, not a setting.

02 · The Stack

Framework
React Native (Expo) — a single TypeScript codebase across iOS and Android.
System of record
On-device SQLite — the only source of truth; no cloud database or sync server.
State
Zustand — lightweight, selector-based UI state over the local data layer.
UI / motion
NativeWind (Tailwind for RN) + React Native Reanimated on the native thread.
AI
OpenAI Chat Completions + Function Calling — the model proposes typed intents.
Retrieval
A local RAG loop whose corpus is a SQLite query — grounded and fully private.

03 · System Architecture Flow

  1. 1SMS Received

    A background task wakes when a bank transaction SMS arrives on-device.

  2. 2Adapter Parse

    A bank-specific adapter sanitizes the raw string into a typed record — regex fragility contained behind a stable interface.

  3. 3Serialized Write

    A p-queue funnels every write into SQLite one at a time, eliminating write-lock contention.

  4. 4Local Context

    The AI layer queries on-device SQLite (spend, pacing) — no financial data leaves the phone.

  5. 5Dry-Run Proposal

    A function call renders a Review & Confirm card. The model proposes an intent; it never writes.

  6. 6Commit / Briefing

    On confirm, the SQLite mutation runs; a daily cron pushes an AI Morning Briefing to the lock screen.

04 · Deep Technical Breakdown

Serialized writes: taming SQLite write-lock contention

SQLite permits a single writer. When several bank SMS arrive in quick succession, concurrent write attempts collide — surfacing as database is locked errors and dropped or partial transactions, which is unacceptable for a financial ledger. The fix is architectural, not a retry loop: every write is routed through a p-queue with bounded concurrency, turning a bursty race into a deterministic, ordered stream.

// A single-concurrency queue serializes every SQLite write.
import PQueue from "p-queue";

const writeQueue = new PQueue({ concurrency: 1 });

export function enqueueWrite(tx: TransactionRecord) {
  // Bursty SMS become an ordered stream — no write-lock contention.
  return writeQueue.add(() => db.runAsync(INSERT_TX, tx));
}

Containing regex fragility with the Adapter Pattern

Bank SMS have no standard — formats differ by bank and transaction type and change without notice. A monolithic parser would be brittle and fail silently, corrupting the ledger. Instead, each format is isolated behind an adapter with a shared interface: raw, untrusted strings are sanitized and validated at the boundary, and an unrecognized message is rejected cleanly rather than mis-parsed. Supporting a new bank is an additive change — a new adapter, not a rewrite of working code.

interface SmsAdapter {
  matches(raw: string): boolean;
  parse(raw: string): TransactionRecord; // throws on malformed input
}

// Open/closed: register a new bank without touching the pipeline.
const ADAPTERS: SmsAdapter[] = [hdfcAdapter, iciciAdapter, sbiAdapter];

export function toTransaction(raw: string): TransactionRecord | null {
  const adapter = ADAPTERS.find((a) => a.matches(raw));
  return adapter ? adapter.parse(sanitize(raw)) : null; // reject, never guess
}

Function calling with a "dry-run" safety boundary

The chat agent can manage debts through natural language, but a function call is treated as an intent, not an action. The model never writes to the database directly — its proposed call is rendered as a Review & Confirm card that shows exactly what will change and requires explicit confirmation before the SQLite transaction executes. This dry-run → confirm → commit flow makes a non-deterministic model safe to point at a real ledger.

// The model returns a tool call; the UI — not the model — commits.
const call = completion.choices[0].message.tool_calls?.[0];

if (call?.function.name === "record_debt") {
  const intent = RecordDebt.parse(JSON.parse(call.function.arguments));
  showReviewCard(intent, {
    onConfirm: () => enqueueWrite(intent.toTransaction()), // commit
    onCancel: () => {},                                    // no-op, no mutation
  });
}

Proactive RAG: the Morning Briefing

A daily background job runs a small local RAG loop: it queries SQLite for the relevant window (yesterday's spend, month-to-date pacing, upcoming obligations), assembles a compact context window, generates a natural-language briefing, and pushes it to the lock screen. The retrieval corpus is the user's own local data — the "R" in RAG is a SQLite query — so the entire loop stays private and grounded in real numbers.

// Retrieval = a local SQLite query. Nothing leaves the device.
const context = {
  yesterday: await db.getAllAsync(SPEND_YESTERDAY),
  pacing: await db.getFirstAsync(MONTH_PACING),
  upcoming: await db.getAllAsync(UPCOMING_OBLIGATIONS),
};

const briefing = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: buildBriefingPrompt(context) }],
});

await pushLockScreenNotification(briefing.choices[0].message.content);

Explore the source

Local-first data layer, the ingestion pipeline, and the AI safety boundary.

Sable on GitHub