## Inspiration
Every personal finance app on the market — Mint, YNAB, Cleo, Copilot, Monarch — has a memory of your **transactions**. None of them has a memory of **you**. The same app that tracks your $47 DoorDash order has no idea you ordered it because you were stuck at work on a Tuesday, or that you're bulking this month and the elevated food spend is exactly what you wanted, or that you already told it last week "I'm fine, the birthday party is why my spending is high."
We wanted to build the finance app a smart friend would build for you — one that holds the context you give it, watches for patterns in your real spending, and proposes nudges you can accept, modify, or refuse, with the agency staying with you.
## What it does
MoneyMind is a personal finance agent with three loops that all close end-to-end on real user data:
**1. The memory loop.** Tell it once — "I'm bulking this month", "my dog needs surgery next week", "I'm cooking again now that the move is done" — and it writes a memory document to MongoDB Atlas with a Voyage AI vector embedding. The next day, the next week, the next month, when you ask "how was my food spend?", the agent recalls the relevant memories via 1024-dim cosine vector search and reasons about your spend in that context. Forget anything you no longer want it to remember by saying so — the agent soft-deletes after a single-turn confirmation flow.
**2. The intervention loop.** When MoneyMind detects a real anomaly in your spending (z-score check against the trailing window per category), it doesn't just notify — it proposes a structured intervention: "Cap your food delivery at $300/month?" with Accept / Modify / Decline buttons. Accepting a cap *materializes a real budget* in Atlas the same second; the dashboard's budget progress bar reflects it on the next render. Caps you accept get watched against your real spend going forward.
**3. The ingestion loop.** Drop any bank or credit-card statement PDF into the chat composer and Gemini 2.5 Flash on Vertex AI extracts every transaction, categorizes each one against our 43-entry vocabulary, deterministically canonicalizes the merchant names (so "TIM HORTONS #4126 RICHMOND HILL" and "TIM HORTONS #6990 RICHMOND HILL" roll up to one merchant), splits payments-to-card out of the spend stream, and bulk-inserts to `transactions` — all in 5-8 seconds, with a 6-step Server-Sent Events progress timeline streaming back to the chat so the user can see exactly what the model is doing. The dashboard fills with real data the moment it's done.
Eighteen LangGraph tools sit behind the chat. The agent decides when to call which one. The user just talks.
## How we built it
**The agent layer.** LangGraph's `create_react_agent` running Gemini 2.5 Flash via `langchain-google-vertexai`. Eighteen native tools wired in, grouped by feature:
- **Memory** (3): `recall_memory`, `write_memory`, `forget_memory` — vector recall + soft-delete confirmation
- **Goals** (4): `write_goal`, `list_goals`, `abandon_goal`, `check_goal_pace`
- **Budgets** (3): `set_budget`, `list_budgets`, `abandon_budget` — upsert semantics, never deletes
- **Interventions** (3): `propose_intervention`, `respond_to_intervention`, `log_outcome`
- **Analytics** (3): `query_transactions`, `get_spend_anomaly`, `summarize_week`
- **Context & Crons** (2): `update_user_context`, `schedule_reminder`
Each tool takes the `user_id` via LangGraph `InjectedState` so the LLM never sees or forges it — the security boundary is at the graph layer, not in prompt instructions.
**The MongoDB MCP integration.** We integrated the official [MongoDB MCP Server](https://www.mongodb.com/docs/mcp-server/) via `langchain-mcp-adapters`. The MCP subprocess is spawned with `--readOnly` and its tools join the 18 native tools in the agent's tool registry as `mongo_*`. The agent can inspect collection schemas, run aggregations, and tune its own queries — but cannot mutate the database from the MCP path. A `MONGODB_MCP_DISABLE` env switch is wired so a regression in the upstream package can't kill the demo.
**The data layer.** One MongoDB Atlas cluster does four jobs:
- **Operational ledger** — `transactions`, `goals`, `budgets`, `interventions`, `outcomes`, `reminders`, `user_context`, `inbox_messages`
- **Vector memory** — `memories` collection with 1024-dim cosine Atlas Vector Search index. Voyage AI `voyage-3` embeddings written **manually on insert** (not Atlas auto-embed — we explicitly call `embed_document()` so we own the contract)
- **LangGraph state** — `langgraph_store` collection, managed
- **MCP read tools** — the same Atlas, exposed via the MCP server
**The ingestion pipeline.** PDF → Gemini multimodal call (single round-trip extracts + categorizes against the vocabulary block in the prompt) → deterministic Python canonicalizer that overrides the LLM's category for high-confidence merchants (Tim Hortons → `food.coffee` always) → SHA-256 source-hash dedupe so re-uploads return 0 inserted → bulk `insert_many` to `transactions`. The route emits SSE so the chat shows real-time per-stage progress instead of a spinner.
**The frontend.** Next.js 15 + Tailwind on Vercel. Streaming chat via `/api/chat` proxy. Real-time SSE-driven `ImportProgress` card for V4 PDF uploads. Single-column chat thread with first-class card types (intervention cards, statement cards, import-progress cards) that morph into one another as state changes.
**The backend + agent.** Single Docker container on Railway with supervisord running FastAPI on `:8000` and the agent on loopback `:8001`. Clerk JWT on the frontend → `Authorization: Bearer` to FastAPI → `X-MoneyMind-User-Id` loopback header to the agent. Async-clean: every request stays on a single asyncio event loop end-to-end so `motor` cursors never cross boundaries.
## Challenges we ran into
**LangGraph + Vertex AI shape drift.** `gemini-2.5-flash` returns content as `list[dict]` chunks, not strings — the streaming layer assumed strings and broke silently. Fixed by flattening before yielding to the SSE writer. Documented in `docs/decisions.md` 2026-06-01.
**Atlas auto-embed was a trap.** We started by assuming Atlas would generate the Voyage embedding on insert. It does not — Atlas auto-embed exists but isn't on by default for our cluster tier. Writers must populate `embedding` themselves before insert. Switched to explicit `embed_document()` calls in `write_memory`. Two days lost to this.
**The motor cursor + event loop bug.** Originally `stream_chat` was sync and ran the graph via `asyncio.run` — works fine for one chat turn, blows up on the second because the motor cursors are bound to the first turn's now-closed loop. Rewrote the streaming path as `astream_chat` end-to-end so every layer (FastAPI handler → agent graph → motor query) shares one event loop. Failure was nondeterministic: cold container → first chat works → second chat returns "Event loop is closed". Brutal to diagnose.
**The intervention-card path bypassed the agent.** Slide-8 of the pitch promised the user accepts a cap and the dashboard immediately shows it. Caught the bug 48 hours before submission: the intervention card's "Accept" button hits the REST API directly, never goes through the LLM, so the system prompt's "after respond_to_intervention you must call set_budget" rule never fires. The cap was marked responded but no budget row was ever inserted. Fixed by adding a backend-side auto-materialization: when the route sees `type=cap` + `user_response=accepted`, it calls `set_budget` directly with the proposed (or user-modified) params. Five new hermetic tests cover the accept / decline / non-cap / malformed paths.
**The V4 progress UX.** First version of the PDF upload showed a static "Reading your statement…" bubble for 5-30 seconds while Gemini extracted. Users (us, on camera) had no idea if it was working. Rebuilt the whole path as SSE: backend yields `received → dedupe → extracting → extracted → categorizing → saving → done` frames, frontend morphs a 6-step animated timeline card in real time. Added `asyncio.to_thread` around the blocking Gemini call so the event loop stays free to flush each SSE frame before the model returns.
**PDF parsing accuracy.** Initially extraction missed transactions on multi-page statements when the first page had a "summary" section above the line items. Re-tuned the prompt with explicit "skip headers, footers, summary blocks, 'this is not a billing statement' disclaimers" instructions. Hardened it against the `?` character that some statement exporters substitute for apostrophes (`MCDONALD?S` etc.) by stripping in canonicalizer. End-to-end on real Amex Cobalt statements now matches the bank's own "Summary for this billed period" line **to the cent**.
## Accomplishments we're proud of
**The numbers add up.** Drop our test Amex statement: pipeline reports `$1,709.88 across 69 transactions, 3 payments excluded` — those three numbers match the bank's printed summary verbatim. Not approximately. Exactly. That's the bar we held the ingestion pipeline to.
**Eighteen tools, all live, all tested.** Every tool has hermetic unit tests, the categorizer + canonicalizer have fixture tests against the four real Amex PDFs we used during development, and the V4 ingest pipeline has SSE-aware streaming tests. Backend: 94/94 passing. Agent: 222+ tests across 17 tool modules. Zero `TODO` comments remain in the agent layer.
**Single-event-loop end-to-end.** Vercel proxy → FastAPI → loopback to agent → LangGraph → motor → Atlas. Async-clean, no thread pools in the chat path, no cursor leakage between requests. The whole stack is one big `async def`.
**Dashboard is real data only.** Every visible widget reads from Atlas. The last placeholder constant was deleted on 2026-06-08 (V6 budgets ship). No "demo" pills, no SAMPLE tags, no synthetic-data warnings. What's on screen is what's in the database for the signed-in user.
**Soft-delete with single-turn confirmation.** `forget_memory` has a high-confidence threshold (vector cosine ≥ 0.75); if the agent isn't sure which memory the user meant, it asks once with quoted summary, and accepts the user's "yes" using a memory_id-free re-call against the quoted summary. Atlas's vector index doesn't support filterable fields on top of `$vectorSearch`, so the soft-delete filter (`deleted_at: null`) is applied in a post-search `$match` — we figured out the right shape and documented it.
## What we learned
**Atlas Vector Search is a different mental model from a SQL FK.** The `$vectorSearch` stage runs FIRST and its filter clause supports only fields registered as filterable on the index. Anything you want to filter mid-pipeline has to either be in the index or be applied via a post-search `$match`. We needed `deleted_at` filtering for soft-deletes but couldn't add it to the index without re-indexing — so the post-search `$match` pattern locked in. Documented in `decisions.md`.
**LLMs + structured output isn't free.** Gemini 2.5 Flash with `with_structured_output` works beautifully for the V4 ingestion pipeline (72 transactions per PDF, $0.02 per extract, ~6 second latency on a warm container) but we had to babysit the schema descriptions — every Pydantic `Field(description=...)` matters because the model treats them as prompt instructions. "POSITIVE for charges, NEGATIVE for payments" was a description that mattered: without the polarity hint, the model would flip the sign roughly 20% of the time.
**Deterministic + LLM is stronger than either alone.** Our merchant canonicalizer is pure Python regex + a curated rule table (60+ entries tuned against real Amex statements). The LLM does category extraction; the canonicalizer overrides high-confidence cases (Tim Hortons → `food.coffee` always, regardless of model wander). Cheaper, faster, and more deterministic than letting Gemini decide every merchant's identity.
**The agent loop only works if security is enforced structurally.** `InjectedState` from LangGraph lets us pass `user_id` to every tool without exposing it in the LLM's context window. That means: even if the user types "show me user_482's transactions", the model literally cannot generate the right tool call — the user_id slot is unbindable from prompt content. We rejected the alternative (read user_id from the LLM-emitted tool input) on day one.
**Progress UX is a product feature.** The V4 PDF upload took 5-30 seconds before we built the SSE pipeline. Users perceived it as broken at >8s. With the 6-step progress timeline, the SAME backend latency reads as "fast" because every step is visible. We learned the hard way that "make it visibly busy" beats "make it actually faster" when you can't make it actually faster.
## What's next for MoneyMind
**Multi-statement ingestion + reconciliation.** Drop 6 months of statements and have the agent reconcile them into a unified spend history with cross-statement dedupe (the same Amazon order appearing on two statements — credit + refund — should net to zero, not double-count).
**Pattern-graduation.** Right now memory writes are flat. Next iteration: a low-confidence `reaction` memory ("user bought DoorDash again") gets superseded by a higher-confidence `pattern` memory ("user orders DoorDash 4+ nights/week") via a consolidation pass. The agent already has the semantic primitives; the consolidation cron is the missing piece.
**Outcome learning.** `log_outcome` already writes before/after spend deltas with a server-computed `delta_pct`. The next step is feeding outcomes back into the intervention proposer — "you accepted a food cap last month and your delivery spend dropped 34%, want to try the same on coffee?" The data shape is in place; the read path is post-hackathon.
**Joint accounts.** Two users, one Atlas memory store, with conflict resolution when memories disagree ("Alex said he's bulking" vs "Jordan said Alex needs to cut back"). The user_id isolation is the foundation; the relaxation is the work.
**Native mobile.** The chat-first paradigm is the right one for mobile. The dashboard reads as a stats screen but the agent is the product.
Log in or sign up for Devpost to join the conversation.