Finly has a chat assistant that can answer questions about your spending and also change your data: add an expense, set a budget, create a category. Giving a language model write access to someone's finances is easy to build and easy to regret. The rule we settled on is that the model proposes and the server disposes.

Asking in the prompt is not a control

Our first instinct, like most people's, was to tell the model to confirm with the user before saving anything. The prompt still says things like that, but a prompt is a request, not a guarantee. A comment in the code says it better than we can: instructions to ask for confirmation are not enforcement, and the real enforcement is somewhere else.

So the write tools do not write. There is a fixed list of them: adding expenses and income, recurring transactions, categories and budgets, accounts, tags, and corrections. When the model calls one, the server intercepts the call, stores the intended action under a random token with a five-minute lifetime, and returns a preview instead. Nothing touches the database.

The confirm step

The preview is generated on the server, with the amount formatted in its real currency, so the words the user reads come from the action that will run and not from whatever the model paraphrased. The app shows a card with Confirm and Cancel. Confirm sends only the token.

On the way in, the token is checked as a well-formed ID, looked up, and deleted before anything executes, so a double tap or a replayed request cannot run twice. The stored action must belong to the user making the request. An expired or used token returns a plain message asking the user to ask again. Cancel does nothing on the server; the pending action simply expires.

Do not trust the model's card either

The model is asked to render the confirmation card itself, copying the token. That turned out to be the weak point. In production we saw a model produce a card-shaped reply with a made-up token and no tool call at all, imitating a result it had seen in its own conversation history.

Two things fixed it. The server now builds the real card from the real tool result and discards any confirmation card in the model's text that was not minted by a tool call in that same turn. And if a reply looks like an imitated write, the server retries once with a tool call required. If the model still will not call a tool, the imitation is stripped, so at least nothing fake renders.

Rules the server knows should not live in a prompt

If the user has two or more accounts and did not say which one, the prompt tells the model to ask. A smaller model does not reliably do that, so the server checks too: it returns an error with the missing choice and the pending fields, and the model turns that into a quick-reply card. The same shape handles a missing category: propose creating it, then retry the expense without making the user repeat themselves.

Corrections are narrow on purpose. The assistant can only fix a transaction it created earlier in the same conversation, using the ID from that confirmation. It is told never to guess or search for an ID. Anything older goes to the edit screen. The tool loop is capped at three rounds.

One soft spot we should be honest about: when a confirmed action needs a currency conversion and the exchange-rate lookup fails, the code logs a warning and continues at one to one. We would rather it blocked the action.

Next field noteA market-data service with no API keys