I'm a software engineer, and I built a budgeting app because I was tired of guessing where my money went. The part people ask about most is the AI assistant, so this post is about the one design choice that shaped all of it: the AI can read a snapshot of your finances, and it has no way to do anything else.
The stack is Next.js, Prisma, Clerk for auth, Plaid for bank connections, and Claude for the assistant, streamed through the Vercel AI SDK. Here is how the read-only part works.
Layer one: the bank connection only reads
When someone connects a bank, the Plaid Link token asks for exactly one product, Transactions. There is no payment initiation product in the request, so even a bug on my side cannot create a transfer. It is a boundary the bank enforces, not a promise in my code.
Layer two: the model gets a snapshot, not a database
The assistant does not run queries and it has no tools. Before each chat turn, the server builds a text summary of the user's finances (account balances, three months of cash flow, spending by category, budgets, goals, and upcoming recurring charges) and passes it as the system message:
const systemContext = await buildAiContext();
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
system: systemContext,
messages: messagesForModel,
maxOutputTokens: 1024,
});
The model can only talk about what is in that snapshot. It cannot fetch more, write anything back, or call out to anything. If the model misbehaves completely, the worst outcome is a bad answer on screen.
I considered giving it tools, so it could look up arbitrary date ranges. I decided against it. Every tool is a new way for a prompt to cause a side effect, and for a money app the extra flexibility is not worth the new attack surface. A bigger or better organized snapshot solves most of what tools would have solved.
Layer three: cache the snapshot, retrieve the rest
The snapshot is the expensive part of each request, and it barely changes between messages in one conversation. So it sits in the system message with Anthropic's prompt caching turned on:
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
},
Follow up questions in the same session reuse the cached prefix instead of paying for it again.
For general money questions that are not about the user's own data, like "what is an avalanche payoff", I retrieve a few articles from a small knowledge base and put them in the last user message. They go there on purpose. Retrieval results differ on every turn, so putting them in the cached system message would break the cache every time. A failed lookup falls back to the plain messages, so retrieval can never take the chat down.
Layer four: boring guardrails
Monthly message quotas are enforced per plan and counted in the database, and a Redis rate limit sits in front of everything as an abuse guard. The request body is validated with Zod, and the message list only allows user and assistant roles. A client cannot smuggle in a fake system message, because the Messages API has no system role inside the list at all, and I restrict the schema to match.
What I would tell another developer
- Decide what the model must never be able to do, then make that impossible at the boundary, not in the prompt.
- Prefer a good snapshot over tools until you can name a specific question the snapshot cannot answer.
- Put stable context in the cached prefix and volatile context in the user turn.
If you want to see the result, the app is FinSight at https://finsight.it.com, and there is a free tool that finds recurring charges in a bank statement CSV without an account: https://finsight.it.com/free-subscription-finder. I'm happy to answer questions about any of this in the comments.
Top comments (0)