← All posts

Build log

When My App Got a Brain

Building Lance, the agent inside FreelanceCFO, and everything it got wrong on the way.

Lance inside FreelanceCFO: a list of past conversations on the left, one of them titled Ignore all previous instructions, and an answer breaking down three months of spending on the right.
Lance on my own account. Yes, one of those conversations is me telling it to ignore all previous instructions.

The goal was one sentence: “See how many clients have pending invoices and shoot them a mail.” Getting an AI to understand that took an afternoon. Getting it to do that safely, for money, on a free model, took most of three months, and almost everything I learned came from the parts that broke.

before lance, there were insights

FreelanceCFO’s first AI feature was small. Every few hours it looked at your numbers and wrote one short briefing: your runway dropped, a client is paying slower, tax season is coming. It ran on Groq’s free tier, on an 8B Llama model.

The first real lesson came in July, and it wasn’t about intelligence at all. Everything that reaches the model’s prompt is text somebody typed: transaction descriptions, client names, even the model’s own earlier briefings, which get fed back in as memory. If a client’s name is an instruction, the model reads an instruction. So every untrusted string got cleaned on the way in (invisible characters, bidirectional text tricks, length caps), every output with a link got thrown out, and the model went from 8B to 70B, because how hard a model is to talk into things scales with how capable it is.

the three weeks insights said nothing

On 18 August, Groq discontinued the Llama model. Fine: swap the model name to gpt-oss-120b, ship it, done. The diff touched one line.

From that day, every single user got “Unable to generate a briefing right now.” For three weeks.

The new model is a reasoning model. It thinks before it answers, and Groq counts that hidden thinking against the same max_tokens budget as the answer. The budget was 180 tokens, set back when every token was visible output. So the model spent all 180 thinking and returned nothing. Worse, “empty response” was the only error branch in the whole function that logged nothing, so the one place that should have named the problem in five minutes was silent, and the search went everywhere else first.

Watch outSwapping to a reasoning model is not a one-line change. Its thinking eats your token limit, and JSON mode makes it worse. In October it broke again the same way: 780 tokens plus forced JSON output, all spent thinking, and Groq rejecting the empty result. The fix both times was more room, no forced JSON, and parsing the answer out of whatever the model returns.

designing an agent you can trust with money

Lance got designed on 12 September, and the first rule wrote itself: Lance never acts. It reads your data, reasons, and makes a proposal: here’s a draft to Northwind, 41 days overdue. You read it, you change it, you press send. There is no tool it can call that sends anything.

The second rule is the one I’d tattoo on every agent: a model may choose which question to ask, never what the answer is. Lance doesn’t get a database. It gets a short list of named tools like clients with overdue invoices, past N days, written by us and checked by the server. And no tool takes a user ID as an argument. Ever. The server pins every tool to whoever is signed in, so even a perfect prompt injection can’t ask for someone else’s books. It’s not a convention, it’s the security boundary.

Then the boring discovery: the headline sentence was impossible. FreelanceCFO had no invoices table. So before Lance could chase an invoice, the app needed invoices, a document renderer, an email composer, and bounce handling. The agent was the last 10%.

Lance on demo data: it answers that two clients owe 4,200 dollars, then offers one suggestion, a nudge to Northwind Outfitters with a Read and edit button. Nothing happens until you act.
Demo data. Lance drafts the nudge; nothing happens until you press the button.

charging for it without getting robbed

Lance runs on credits, five cents a run, and money code is where AI-flavoured bugs stop being funny. The first version checked your balance, ran the model, then charged you. A re-read of the diff found the hole: click twice with one run of credit left, and both clicks pass the check before either pays. So now it charges first and refunds if the run fails, keyed so a retry can’t refund twice. And balances aren’t a number in a column; they’re a ledger of every credit and debit, so “where did my money go” always has an answer.

The five cents, by the way, came from nowhere. Nobody had measured what a run costs. So now every run records the tokens it actually used and how many tool calls it made, so the price can be argued with data instead of vibes.

the limitations, honestly

Everything here runs on free tiers, and free tiers have edges you can feel.

  • Lance remembers six turns. Every turn resends the whole conversation, so cost grows with the square of its length, and the free tier is limited per minute and per day. Six is enough for “yes, draft that one” to work. Before threading existed, Lance would end answers with “want me to draft a reminder?” and then couldn’t hear the yes.
  • A rate limit is a wait, not an error. When the free tier’s per-minute ceiling hits, Lance treats it as “try again shortly” instead of a failure, and the run that hit it doesn’t cost you anything.
  • The backup provider lasted a day. On 15 September I added Cerebras as a second provider. Same day, every Lance run started failing, because the Cerebras account was answering every call with 402 Payment Required and my browser still had it saved as the preference. It got pulled from the allow-list the same day.
  • It’s scoped to your money, on purpose. A request framed very politely and very financially, “build me a single-page ledger tool,” walked straight past its rules, and it happily spent a paid run writing a whole HTML app. Now it declines anything that isn’t your invoices, clients or cash.

A model may choose which question to ask, never what the answer is.

rule R18, written into the codebase the day Lance shipped

it snitches, quietly

That ledger-tool trick led to my favourite piece of the whole thing. Lance has one extra tool it can call silently: flag suspicious activity. When someone tries to talk it out of its rules, it answers normally and also files a flag. The flag never shows up in your answer or in the “what Lance read” panel you can see, because that would tip off exactly who it’s meant to catch. The flagged conversation is encrypted with the user’s own key, and opening one is a separate admin action that writes its own audit log. Reading a flagged chat has to be a decision, not a side effect of opening a dashboard.

The conversation in the screenshot up top, the one called “Ignore all previous instructions”? That was me, testing exactly this. Building the thing doesn’t exempt you from trying to break it.

where it’s at

Since September Lance has picked up forecasting, spending breakdowns, client payment habits and reconciliation, all as more named tools, never as more freedom. It still can’t send a single email on its own, and I don’t plan on changing that. The most useful thing an AI in a finance app can be is a very fast assistant that does the reading and the drafting, then gets out of the way while you decide.

If you’re a freelancer and want to see it, it’s in FreelanceCFO, and there’s more on how the app works in the case study.

  • FreelanceCFO
  • AI agents
  • Build log
  • Security
All posts